diff --git a/contents/AwsScheduler.md b/contents/AwsScheduler.md index 4838b17..c7b28f2 100644 --- a/contents/AwsScheduler.md +++ b/contents/AwsScheduler.md @@ -135,6 +135,8 @@ The role must have permissions to publish messages: Brighter can create the role automatically if it doesn't exist: ```csharp +using Paramore.Brighter.MessageScheduler.AWS.V4; + var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-scheduler-role") { MakeRole = OnMissingRole.Create // Brighter will create role if missing @@ -221,7 +223,11 @@ last of them. Configure AWS Scheduler with minimal settings: ```csharp +using Amazon; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Paramore.Brighter; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.MessageScheduler.AWS.V4; using Paramore.Brighter.MessagingGateway.AWSSQS.V4; @@ -255,6 +261,12 @@ var app = builder.Build(); If using `FireAwsScheduler` approach, configure the Dispatcher to handle scheduler messages: ```csharp +using System; +using Amazon; +using Microsoft.AspNetCore.Builder; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.AWS.V4; using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; using Paramore.Brighter.MessagingGateway.AWSSQS.V4; @@ -278,20 +290,20 @@ var producerRegistry = new SnsProducerRegistryFactory( var subscriptions = new[] { new SqsSubscription( - new SubscriptionName("scheduler-message-subscription"), - new ChannelName("scheduler-message-channel"), - new RoutingKey("message-scheduler-topic"), + subscriptionName: new SubscriptionName("scheduler-message-subscription"), + channelName: new ChannelName("scheduler-message-channel"), + routingKey: new RoutingKey("message-scheduler-topic"), bufferSize: 10, timeOut: TimeSpan.FromSeconds(30), messagePumpType: MessagePumpType.Proactor ) }; -builder.Services.AddServiceActivator(options => +builder.Services.AddConsumers(options => { options.Subscriptions = subscriptions; }) -.UseExternalBus(configure => +.AddProducers(configure => { configure.ProducerRegistry = producerRegistry; }) @@ -311,6 +323,10 @@ var app = builder.Build(); AWS EventBridge Scheduler supports organizing schedules into groups: ```csharp +using System.Collections.Generic; +using Amazon.Scheduler.Model; +using Paramore.Brighter.MessageScheduler.AWS.V4; + var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-scheduler-role") { Group = new SchedulerGroup @@ -336,6 +352,8 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-scheduler-role EventBridge Scheduler supports flexible time windows for cost optimization: ```csharp +using Paramore.Brighter.MessageScheduler.AWS.V4; + var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-scheduler-role") { FlexibleTimeWindowMinutes = 5 // Execute within 5-minute window @@ -349,6 +367,8 @@ A flexible time window allows AWS to batch executions for better efficiency. The Customize how Brighter generates scheduler IDs for messages and requests: ```csharp +using Paramore.Brighter.MessageScheduler.AWS.V4; + var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-scheduler-role") { // Custom scheduler ID for messages @@ -379,21 +399,25 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-scheduler-role Schedule a command to execute after a delay: ```csharp -public class OrderService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class OrderService( + IAmACommandProcessor commandProcessor, IOrderRepository repository, ILogger logger) +{ public async Task CreateOrder(Order order) { - await _repository.SaveAsync(order); + await repository.SaveAsync(order); // Schedule payment processing for 30 minutes later - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( TimeSpan.FromMinutes(30), new ProcessPaymentCommand { OrderId = order.Id, Amount = order.Total } ); - _logger.LogInformation( + logger.LogInformation( "Scheduled payment processing for order {OrderId}: {SchedulerId}", order.Id, schedulerId @@ -407,21 +431,24 @@ public class OrderService Schedule for a specific time: ```csharp -public class ReportService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class ReportService(IAmACommandProcessor commandProcessor, ILogger logger) +{ public async Task ScheduleDailyReport() { // Schedule for tomorrow at 9 AM UTC var tomorrow9AM = DateTimeOffset.UtcNow.Date.AddDays(1).AddHours(9); - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( tomorrow9AM, new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date } ); - _logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); + logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); } } ``` @@ -436,28 +463,25 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Paramore.Brighter; -public class OrderService +public class OrderService( + IAmAMessageSchedulerAsync scheduler, IOrderRepository repository, ILogger logger) { - // ... _repository and _logger, injected as your application supplies them - private readonly IAmACommandProcessor _commandProcessor; - private readonly IAmAMessageSchedulerAsync _scheduler; - public async Task CancelOrder(Guid orderId) { - var order = await _repository.GetAsync(orderId); + var order = await repository.GetAsync(orderId); // Cancel scheduled payment if (!string.IsNullOrEmpty(order.PaymentSchedulerId)) { - await _scheduler.CancelAsync(order.PaymentSchedulerId); - _logger.LogInformation( + await scheduler.CancelAsync(order.PaymentSchedulerId); + logger.LogInformation( "Cancelled scheduled payment for order {OrderId}", orderId ); } order.Status = OrderStatus.Cancelled; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); } } ``` @@ -467,24 +491,28 @@ public class OrderService Schedule an event to be published: ```csharp -public class TrialService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class TrialService( + IAmACommandProcessor commandProcessor, IUserRepository repository, ILogger logger) +{ public async Task StartTrial(User user) { user.TrialStartDate = DateTime.UtcNow; user.TrialEndDate = DateTime.UtcNow.AddDays(30); - await _repository.SaveAsync(user); + await repository.SaveAsync(user); // Schedule trial expiry reminder for 7 days before end var reminderDate = user.TrialEndDate.AddDays(-7); - var schedulerId = await _commandProcessor.PublishAsync( + var schedulerId = await commandProcessor.PublishAsync( reminderDate, new TrialExpiringEvent { UserId = user.Id, DaysRemaining = 7 } ); - _logger.LogInformation( + logger.LogInformation( "Scheduled trial expiry reminder for user {UserId}: {SchedulerId}", user.Id, schedulerId @@ -512,6 +540,8 @@ you say otherwise — see [AWS Scheduler Factory Options](#aws-scheduler-factory ### 1. Use Direct to Target for Messages ```csharp +using Paramore.Brighter.MessageScheduler.AWS.V4; + // Good - Direct scheduling (default) var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-role") { @@ -520,6 +550,8 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-role") ``` ```csharp +using Paramore.Brighter.MessageScheduler.AWS.V4; + // Bad - Unnecessary indirection for messages var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-role") { @@ -529,7 +561,7 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-role") ### 2. Limit IAM Role Permissions -```csharp +```json // Good - Specific resource ARNs { "Effect": "Allow", @@ -541,7 +573,7 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-role") } ``` -```csharp +```json // Bad - Overly permissive { "Effect": "Allow", @@ -553,6 +585,10 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "my-role") ### 3. Use Scheduler Groups for Organization ```csharp +using System.Collections.Generic; +using Amazon.Scheduler.Model; +using Paramore.Brighter.MessageScheduler.AWS.V4; + // Good - Organized by feature/team var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") { @@ -571,6 +607,9 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") ### 4. Handle OnConflict Appropriately ```csharp +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.AWS.V4; + // Good - Explicit conflict handling var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") { @@ -582,20 +621,31 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") ### 5. Use Custom Scheduler IDs for Idempotency ```csharp -// Good - Idempotent based on business key -GetOrCreateRequestSchedulerId = request => +using Paramore.Brighter.MessageScheduler.AWS.V4; + +var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") { - if (request is ProcessPaymentCommand cmd) + // Good - Idempotent based on business key + GetOrCreateRequestSchedulerId = request => { - return $"payment-{cmd.TransactionId}"; // One schedule per transaction + if (request is ProcessPaymentCommand cmd) + { + return $"payment-{cmd.TransactionId}"; // One schedule per transaction + } + return request.Id.ToString(); } - return request.Id.ToString(); -} +}; ``` ```csharp -// Bad - Always creates new schedule -GetOrCreateRequestSchedulerId = request => Guid.NewGuid().ToString(); +using System; +using Paramore.Brighter.MessageScheduler.AWS.V4; + +var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") +{ + // Bad - Always creates new schedule + GetOrCreateRequestSchedulerId = request => Guid.NewGuid().ToString() +}; ``` ### 6. Monitor AWS Scheduler Metrics @@ -613,6 +663,8 @@ GetOrCreateRequestSchedulerId = request => Guid.NewGuid().ToString(); ### 7. Use Flexible Time Windows for Cost Optimization ```csharp +using Paramore.Brighter.MessageScheduler.AWS.V4; + // Good - Allow flexibility for non-critical schedules var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") { @@ -623,6 +675,10 @@ var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") ### 8. Test with LocalStack ```csharp +using Amazon; +using Microsoft.Extensions.Hosting; +using Paramore.Brighter.MessagingGateway.AWSSQS.V4; + // Good - Test AWS Scheduler locally with LocalStack var awsConnection = new AWSMessagingGatewayConnection( credentials, @@ -653,19 +709,23 @@ var awsConnection = new AWSMessagingGatewayConnection( **Solutions**: ```csharp -// 1. Verify IAM role permissions in AWS Console -// 2. Ensure target exists before scheduling -var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") -{ - MakeRole = OnMissingRole.Create // Auto-create role -}; +using Amazon; +using Paramore.Brighter.MessageScheduler.AWS.V4; +using Paramore.Brighter.MessagingGateway.AWSSQS.V4; -// 3. Verify region matches your resources +// 1. Verify IAM role permissions in AWS Console +// 2. Verify region matches your resources var awsConnection = new AWSMessagingGatewayConnection( credentials, RegionEndpoint.USEast1 // Match your SNS/SQS region ); +// 3. Ensure target exists before scheduling +var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") +{ + MakeRole = OnMissingRole.Create // Auto-create role +}; + // 4. Configure FireAwsScheduler subscription if needed ``` @@ -700,11 +760,17 @@ var awsConnection = new AWSMessagingGatewayConnection( **Solutions**: ```csharp -// Option 1: Overwrite existing schedule -OnConflict = OnSchedulerConflict.Overwrite +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.AWS.V4; + +var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") +{ + // Option 1: Overwrite existing schedule + OnConflict = OnSchedulerConflict.Overwrite -// Option 2: Throw exception and handle in code -OnConflict = OnSchedulerConflict.Throw + // Option 2: Throw exception and handle in code + // OnConflict = OnSchedulerConflict.Throw +}; ``` ### High Costs @@ -718,8 +784,13 @@ OnConflict = OnSchedulerConflict.Throw **Solutions**: ```csharp +using Paramore.Brighter.MessageScheduler.AWS.V4; + // 1. Use flexible time windows -FlexibleTimeWindowMinutes = 5 +var schedulerFactory = new AwsSchedulerFactory(awsConnection, "role") +{ + FlexibleTimeWindowMinutes = 5 +}; // 2. Clean up old schedules await _scheduler.CancelAsync(oldSchedulerId); diff --git a/contents/AzureScheduler.md b/contents/AzureScheduler.md index e4fb46f..7773204 100644 --- a/contents/AzureScheduler.md +++ b/contents/AzureScheduler.md @@ -76,6 +76,8 @@ var clientProvider = new ServiceBusManagedIdentityClientProvider( ### Visual Studio Credentials (Development) ```csharp +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; + var clientProvider = new ServiceBusVisualStudioCredentialClientProvider( "myservicebus.servicebus.windows.net" ); @@ -84,6 +86,8 @@ var clientProvider = new ServiceBusVisualStudioCredentialClientProvider( ### Connection String (Simple, Less Secure) ```csharp +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; + var clientProvider = new ServiceBusConnectionStringClientProvider( "Endpoint=sb://myservicebus.servicebus.windows.net/;SharedAccessKeyName=..." ); @@ -92,7 +96,9 @@ var clientProvider = new ServiceBusConnectionStringClientProvider( ### Default Azure Credentials (Flexible) ```csharp -var clientProvider = new ServiceBusDefaultAzureCredentialClientProvider( +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; + +var clientProvider = new ServiceBusDefaultAzureClientProvider( "myservicebus.servicebus.windows.net" ); ``` @@ -162,7 +168,10 @@ neither can be left out. Configure Azure Service Bus Scheduler with minimal settings: ```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Paramore.Brighter; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.MessageScheduler.Azure; using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; @@ -193,6 +202,11 @@ var app = builder.Build(); Configure the Dispatcher to handle FireAzureScheduler messages: ```csharp +using System; +using Microsoft.AspNetCore.Builder; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Azure; using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; using Paramore.Brighter.MessagingGateway.AzureServiceBus; using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; @@ -228,11 +242,11 @@ var subscriptions = new[] ) }; -builder.Services.AddServiceActivator(options => +builder.Services.AddConsumers(options => { options.Subscriptions = subscriptions; }) -.UseExternalBus(configure => +.AddProducers(configure => { configure.ProducerRegistry = producerRegistry; }) @@ -251,6 +265,8 @@ Customize Azure Service Bus sender behavior: ```csharp using Azure.Messaging.ServiceBus; +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.Azure; var schedulerFactory = new AzureServiceBusSchedulerFactory( clientProvider, @@ -270,6 +286,10 @@ var schedulerFactory = new AzureServiceBusSchedulerFactory( Use a custom time provider for testing: ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.Azure; + var schedulerFactory = new AzureServiceBusSchedulerFactory( clientProvider, new RoutingKey("brighter-scheduler-topic") @@ -286,21 +306,25 @@ var schedulerFactory = new AzureServiceBusSchedulerFactory( Schedule a command to execute after a delay: ```csharp -public class OrderService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class OrderService( + IAmACommandProcessor commandProcessor, IOrderRepository repository, ILogger logger) +{ public async Task CreateOrder(Order order) { - await _repository.SaveAsync(order); + await repository.SaveAsync(order); // Schedule payment processing for 30 minutes later - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( TimeSpan.FromMinutes(30), new ProcessPaymentCommand { OrderId = order.Id, Amount = order.Total } ); - _logger.LogInformation( + logger.LogInformation( "Scheduled payment processing for order {OrderId}: {SchedulerId}", order.Id, schedulerId @@ -314,21 +338,24 @@ public class OrderService Schedule for a specific time: ```csharp -public class ReportService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class ReportService(IAmACommandProcessor commandProcessor, ILogger logger) +{ public async Task ScheduleDailyReport() { // Schedule for tomorrow at 9 AM UTC var tomorrow9AM = DateTimeOffset.UtcNow.Date.AddDays(1).AddHours(9); - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( tomorrow9AM, new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date } ); - _logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); + logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); } } ``` @@ -343,28 +370,25 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Paramore.Brighter; -public class OrderService +public class OrderService( + IAmAMessageSchedulerAsync scheduler, IOrderRepository repository, ILogger logger) { - // ... _repository and _logger, injected as your application supplies them - private readonly IAmACommandProcessor _commandProcessor; - private readonly IAmAMessageSchedulerAsync _scheduler; - public async Task CancelOrder(Guid orderId) { - var order = await _repository.GetAsync(orderId); + var order = await repository.GetAsync(orderId); // Cancel scheduled payment if (!string.IsNullOrEmpty(order.PaymentSchedulerId)) { - await _scheduler.CancelAsync(order.PaymentSchedulerId); - _logger.LogInformation( + await scheduler.CancelAsync(order.PaymentSchedulerId); + logger.LogInformation( "Cancelled scheduled payment for order {OrderId}", orderId ); } order.Status = OrderStatus.Cancelled; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); } } ``` @@ -379,33 +403,33 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Paramore.Brighter; -public class OrderService +public class OrderService( + IAmACommandProcessor commandProcessor, + IAmAMessageSchedulerAsync scheduler, + IOrderRepository repository, + ILogger logger) { - // ... _repository and _logger, injected as your application supplies them - private readonly IAmACommandProcessor _commandProcessor; - private readonly IAmAMessageSchedulerAsync _scheduler; - public async Task ReschedulePayment(Guid orderId, TimeSpan newDelay) { - var order = await _repository.GetAsync(orderId); + var order = await repository.GetAsync(orderId); // Azure Service Bus doesn't support rescheduling // Must cancel existing schedule and create a new one if (!string.IsNullOrEmpty(order.PaymentSchedulerId)) { - await _scheduler.CancelAsync(order.PaymentSchedulerId); + await scheduler.CancelAsync(order.PaymentSchedulerId); } // Create new schedule - var newSchedulerId = await _commandProcessor.SendAsync( + var newSchedulerId = await commandProcessor.SendAsync( newDelay, new ProcessPaymentCommand { OrderId = order.Id, Amount = order.Total } ); order.PaymentSchedulerId = newSchedulerId; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); - _logger.LogInformation( + logger.LogInformation( "Rescheduled payment for order {OrderId}: {NewSchedulerId}", orderId, newSchedulerId @@ -419,6 +443,8 @@ public class OrderService ### 1. Use Managed Identity in Production ```csharp +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; + // Good - Managed Identity (no credentials in code) var clientProvider = new ServiceBusManagedIdentityClientProvider( "myservicebus.servicebus.windows.net" @@ -426,6 +452,8 @@ var clientProvider = new ServiceBusManagedIdentityClientProvider( ``` ```csharp +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; + // Bad - Connection string in code (security risk) var clientProvider = new ServiceBusConnectionStringClientProvider( "Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..." @@ -435,13 +463,17 @@ var clientProvider = new ServiceBusConnectionStringClientProvider( ### 2. Use Separate Scheduler Topic ```csharp +using Paramore.Brighter; + // Good - Dedicated scheduler topic -new RoutingKey("brighter-scheduler-topic") +var schedulerTopic = new RoutingKey("brighter-scheduler-topic"); ``` ```csharp +using Paramore.Brighter; + // Bad - Mixing business messages with scheduler messages -new RoutingKey("orders-topic") // Don't use business topics for scheduling +var schedulerTopic = new RoutingKey("orders-topic"); // Don't use business topics for scheduling ``` ### 3. Handle Reschedule as Cancel + Schedule @@ -463,7 +495,7 @@ var rescheduled = await _scheduler.ReSchedulerAsync(existingSchedulerId, newDela ### 4. Set Appropriate Message TTL -```csharp +```json // Good - Configure topic with appropriate message TTL // In Azure Portal or via ARM template { @@ -498,18 +530,29 @@ var rescheduled = await _scheduler.ReSchedulerAsync(existingSchedulerId, newDela ### 7. Configure Dead-Letter Queue ```csharp +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.Azure; +using Paramore.Brighter.MessagingGateway.AzureServiceBus; + // Ensure dead-letter queue is monitored var subscription = new AzureServiceBusSubscription( new SubscriptionName("scheduler-subscription"), new ChannelName("scheduler-channel"), new RoutingKey("brighter-scheduler-topic"), - deadLetterQueueHandling: true // Enable DLQ handling + subscriptionConfiguration: new AzureServiceBusSubscriptionConfiguration + { + DeadLetteringOnMessageExpiration = true, // Expired messages go to the DLQ (the default) + MaxDeliveryCount = 5 // Dead-lettered after 5 failed deliveries (the default) + } ); ``` ### 8. Test Locally with Azurite ```csharp +using Microsoft.Extensions.Hosting; +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; + // Good - Test with Azurite (Azure Storage Emulator) if (environment.IsDevelopment()) { @@ -536,6 +579,10 @@ if (environment.IsDevelopment()) **Solutions**: ```csharp +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.Azure; +using Paramore.Brighter.MessagingGateway.AzureServiceBus; + // 1. Verify subscription is configured var subscriptions = new[] { @@ -547,7 +594,7 @@ var subscriptions = new[] }; // 2. Ensure topic name matches in factory and subscription -new AzureServiceBusSchedulerFactory(clientProvider, new RoutingKey("brighter-scheduler-topic")) +var schedulerFactory = new AzureServiceBusSchedulerFactory(clientProvider, new RoutingKey("brighter-scheduler-topic")); // 3. Check message TTL in Azure Portal // 4. Verify Service Bus connection @@ -584,6 +631,10 @@ az role assignment create \ **Solution**: ```csharp +using System; +using System.Threading.Tasks; +using Paramore.Brighter; + // Azure doesn't support reschedule - use cancel + schedule pattern public async Task RescheduleAsync(string oldSchedulerId, TimeSpan newDelay, Command command) { @@ -611,26 +662,42 @@ public async Task RescheduleAsync(string oldSchedulerId, TimeSpan newDel **Solutions**: ```csharp +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.Azure; +using Paramore.Brighter.MessagingGateway.AzureServiceBus; + // 1. Check dead-letter queue for error details -// 2. Increase lock timeout +// 2. Increase lock duration var subscription = new AzureServiceBusSubscription( - ... - lockTimeout: 60 // Increase if processing takes longer + // ... + subscriptionConfiguration: new AzureServiceBusSubscriptionConfiguration + { + LockDuration = TimeSpan.FromMinutes(2) // Increase if processing takes longer + } ); -// 3. Add proper exception handling in handlers -public override async Task HandleAsync( - FireAzureScheduler command, - CancellationToken cancellationToken = default) +// 3. Add proper exception handling in the handler of the request you scheduled +public class ProcessPaymentHandlerAsync(ILogger logger) + : RequestHandlerAsync { - try - { - // Your logic - } - catch (Exception ex) + public override async Task HandleAsync( + ProcessPaymentCommand command, + CancellationToken cancellationToken = default) { - _logger.LogError(ex, "Error processing scheduled message"); - throw; // Or handle appropriately + try + { + // Your logic + return await base.HandleAsync(command, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing scheduled message"); + throw; // Or handle appropriately + } } } ``` diff --git a/contents/BrighterSchedulerSupport.md b/contents/BrighterSchedulerSupport.md index 73d72a2..06ec557 100644 --- a/contents/BrighterSchedulerSupport.md +++ b/contents/BrighterSchedulerSupport.md @@ -64,12 +64,19 @@ public interface IAmACommandProcessor Specifies the **absolute time** when the message should be delivered: ```csharp -// ... +using System; +using Paramore.Brighter; + // Schedule for specific date and time var schedulerId = await commandProcessor.SendAsync( new DateTimeOffset(2025, 1, 15, 14, 30, 0, TimeSpan.Zero), new ProcessOrderCommand { OrderId = orderId } ); +``` + +```csharp +using System; +using Paramore.Brighter; // Schedule for 1 hour from now var schedulerId = await commandProcessor.SendAsync( @@ -83,12 +90,19 @@ var schedulerId = await commandProcessor.SendAsync( Specifies a **relative delay** from the current time: ```csharp -// ... +using System; +using Paramore.Brighter; + // Schedule for 30 minutes from now var schedulerId = await commandProcessor.SendAsync( TimeSpan.FromMinutes(30), new ProcessPaymentCommand { TransactionId = txId } ); +``` + +```csharp +using System; +using Paramore.Brighter; // Schedule for 5 seconds from now (useful for retry) var schedulerId = commandProcessor.Send( @@ -106,7 +120,9 @@ All scheduling methods return a `string` representing the **scheduler ID**. This - **Track** scheduled messages in your application ```csharp -// ... +using System; +using Paramore.Brighter; + // Save the scheduler ID for later use string schedulerId = await commandProcessor.SendAsync( TimeSpan.FromMinutes(30), @@ -155,10 +171,22 @@ Your Handler executes To use scheduling, you need to configure a scheduler when setting up Brighter: ```csharp -// ... -services.AddBrighter(options => { ... }) +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; + +services.AddBrighter(options => + { + // ... + }) // Hangfire's own storage is configured with AddHangfire, not on the factory - .UseScheduler(new HangfireMessageSchedulerFactory()) + .UseScheduler(provider => + { + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; + }) .AutoFromAssemblies(); ``` @@ -202,7 +230,12 @@ Use an external scheduler when: When your handler encounters a transient error (e.g., database temporarily unavailable), you can throw a `DeferMessageAction` exception to requeue the message with a delay: ```csharp -public class ProcessOrderHandlerAsync : RequestHandlerAsync +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; +using Paramore.Brighter.Actions; + +public class ProcessOrderHandlerAsync(IOrderService orderService) : RequestHandlerAsync { public override async Task HandleAsync( ProcessOrderCommand command, @@ -210,10 +243,10 @@ public class ProcessOrderHandlerAsync : RequestHandlerAsync { try { - await _orderService.ProcessAsync(command.OrderId, cancellationToken); + await orderService.ProcessAsync(command.OrderId, cancellationToken); return await base.HandleAsync(command, cancellationToken); } - catch (DatabaseUnavailableException ex) + catch (DatabaseUnavailableException) { // Requeue with delay to give database time to recover throw new DeferMessageAction(); @@ -236,7 +269,7 @@ var subscription = new Subscription( routingKey: new RoutingKey("order.process"), messagePumpType: MessagePumpType.Proactor, requeueCount: 3, // Handle at most 3 times: 2 requeues - requeueDelay: TimeSpan.FromSeconds(5), // Wait 5 seconds between attempts + requeueDelay: TimeSpan.FromSeconds(5), // Wait at least 5 seconds between attempts timeOut: TimeSpan.FromMilliseconds(200) ); ``` diff --git a/contents/CustomScheduler.md b/contents/CustomScheduler.md index c23a818..52d2e84 100644 --- a/contents/CustomScheduler.md +++ b/contents/CustomScheduler.md @@ -20,60 +20,81 @@ The **message scheduler** is used by `IAmAMessageProducer` (sync/async) when: ### Message Scheduler Implementation Steps 1. **Schedule Messages** - Implement `IAmAMessageSchedulerAsync` and `IAmAMessageSchedulerSync` to interface with your scheduler: + Implement `IAmAMessageSchedulerAsync` and `IAmAMessageSchedulerSync` to interface with your scheduler. Each interface has five members: schedule at a time or after a delay, reschedule, and cancel. Store a serialized `FireSchedulerMessage` as the job's payload, which is what Brighter's own schedulers do: ```csharp - public class CustomMessageScheduler : IAmAMessageSchedulerAsync, IAmAMessageSchedulerSync + using System; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Paramore.Brighter; + using Paramore.Brighter.JsonConverters; + using Paramore.Brighter.Scheduler.Events; + + public class CustomMessageScheduler(CustomSchedulerAPI scheduler, TimeProvider timeProvider) + : IAmAMessageSchedulerAsync, IAmAMessageSchedulerSync { - private readonly CustomSchedulerAPI _scheduler; + public string Schedule(Message message, DateTimeOffset at) + => scheduler.Schedule(Fire(message, async: false, at)); - public CustomMessageScheduler(CustomSchedulerAPI scheduler) - { - _scheduler = scheduler; - } + public string Schedule(Message message, TimeSpan delay) + => Schedule(message, timeProvider.GetUtcNow() + delay); + + public bool ReScheduler(string schedulerId, DateTimeOffset at) + => scheduler.Reschedule(schedulerId, at); + + public bool ReScheduler(string schedulerId, TimeSpan delay) + => ReScheduler(schedulerId, timeProvider.GetUtcNow() + delay); + + public void Cancel(string id) => scheduler.Cancel(id); + + public Task ScheduleAsync(Message message, DateTimeOffset at, CancellationToken cancellationToken = default) + => scheduler.ScheduleAsync(Fire(message, async: true, at), cancellationToken); + + public Task ScheduleAsync(Message message, TimeSpan delay, CancellationToken cancellationToken = default) + => ScheduleAsync(message, timeProvider.GetUtcNow() + delay, cancellationToken); + + public Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, at)); - public async Task ScheduleAsync(Message message, DateTimeOffset at) + public Task ReSchedulerAsync(string schedulerId, TimeSpan delay, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, delay)); + + public Task CancelAsync(string id, CancellationToken cancellationToken = default) { - return await _scheduler.ScheduleAsync( - new CustomSchedulerObject - { - Data = new { Message = message, Async = true }, - At = at - }); + Cancel(id); + return Task.CompletedTask; } - public string Schedule(Message message, DateTimeOffset at) + // Async records which call scheduled the message, so the handler fires it the same way + private static CustomSchedulerObject Fire(Message message, bool async, DateTimeOffset at) => new() { - return _scheduler.Schedule( - new CustomSchedulerObject - { - Data = new { Message = message, Async = false }, - At = at - }); - } + Data = JsonSerializer.Serialize( + new FireSchedulerMessage { Message = message, Async = async }, + JsonSerialisationOptions.Options), + At = at + }; } ``` + `CustomSchedulerAPI` stands for your scheduler's client. It stores a `CustomSchedulerObject` (a payload and the time it is due), returns an id, and calls you back with the payload when the time comes. + 2. **Handle Scheduled Messages** - When the scheduler triggers, forward the message to Brighter: + When the scheduler triggers, deserialize the payload and send it to Brighter: ```csharp - public class CustomSchedulerHandler - { - private readonly IAmACommandProcessor _processor; - - public CustomSchedulerHandler(IAmACommandProcessor processor) - { - _processor = processor; - } + using System.Text.Json; + using System.Threading.Tasks; + using Paramore.Brighter; + using Paramore.Brighter.JsonConverters; + using Paramore.Brighter.Scheduler.Events; + public class CustomSchedulerHandler(IAmACommandProcessor processor) + { public async Task ExecuteAsync(CustomSchedulerObject obj) { - await _processor.SendAsync(new FireSchedulerMessage - { - Message = obj.Data.Message, - Async = obj.Data.Async - }); + var fire = JsonSerializer.Deserialize(obj.Data, JsonSerialisationOptions.Options)!; + await processor.SendAsync(fire); } } ``` @@ -88,47 +109,72 @@ The **request scheduler** is used by `IAmACommandProcessor` when methods like `S ### Request Scheduler Implementation Steps 1. **Schedule Requests** - Implement `IAmARequestSchedulerAsync` and `IAmARequestSchedulerSync`: + Implement `IAmARequestSchedulerAsync` and `IAmARequestSchedulerSync`. The payload is a serialized `FireSchedulerRequest`, which carries the request's type name and its own serialized form: ```csharp - public class CustomRequestScheduler : IAmARequestSchedulerAsync, IAmARequestSchedulerSync + using System; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Paramore.Brighter; + using Paramore.Brighter.JsonConverters; + using Paramore.Brighter.Scheduler.Events; + + public class CustomRequestScheduler(CustomSchedulerAPI scheduler, TimeProvider timeProvider) + : IAmARequestSchedulerAsync, IAmARequestSchedulerSync { - private readonly CustomSchedulerAPI _scheduler; + public string Schedule(TRequest request, RequestSchedulerType type, DateTimeOffset at) + where TRequest : class, IRequest + => scheduler.Schedule(Fire(request, type, async: false, at)); - public CustomRequestScheduler(CustomSchedulerAPI scheduler) - { - _scheduler = scheduler; - } + public string Schedule(TRequest request, RequestSchedulerType type, TimeSpan delay) + where TRequest : class, IRequest + => Schedule(request, type, timeProvider.GetUtcNow() + delay); + + public bool ReScheduler(string schedulerId, DateTimeOffset at) + => scheduler.Reschedule(schedulerId, at); + + public bool ReScheduler(string schedulerId, TimeSpan delay) + => ReScheduler(schedulerId, timeProvider.GetUtcNow() + delay); + + public void Cancel(string id) => scheduler.Cancel(id); + + public Task ScheduleAsync(TRequest request, RequestSchedulerType type, DateTimeOffset at, + CancellationToken cancellationToken = default) + where TRequest : class, IRequest + => scheduler.ScheduleAsync(Fire(request, type, async: true, at), cancellationToken); + + public Task ScheduleAsync(TRequest request, RequestSchedulerType type, TimeSpan delay, + CancellationToken cancellationToken = default) + where TRequest : class, IRequest + => ScheduleAsync(request, type, timeProvider.GetUtcNow() + delay, cancellationToken); - public async Task ScheduleAsync(T request, SchedulerType type, DateTimeOffset at) + public Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, at)); + + public Task ReSchedulerAsync(string schedulerId, TimeSpan delay, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, delay)); + + public Task CancelAsync(string id, CancellationToken cancellationToken = default) { - return await _scheduler.ScheduleAsync( - new CustomSchedulerObject - { - Data = new - { - RequestType = typeof(T).FullName, - Data = Serialize(request), - Async = true - }, - At = at - }); + Cancel(id); + return Task.CompletedTask; } - public string Schedule(T request, SchedulerType type, DateTimeOffset at) + private static CustomSchedulerObject Fire(TRequest request, RequestSchedulerType type, bool async, + DateTimeOffset at) => new() { - return _scheduler.Schedule( - new CustomSchedulerObject - { - Data = new - { - RequestType = typeof(T).FullName, - Data = Serialize(request), - Async = false - }, - At = at - }); - } + Data = JsonSerializer.Serialize( + new FireSchedulerRequest + { + SchedulerType = type, + RequestType = typeof(TRequest).FullName!, + RequestData = JsonSerializer.Serialize(request, JsonSerialisationOptions.Options), + Async = async + }, + JsonSerialisationOptions.Options), + At = at + }; } ``` @@ -136,23 +182,18 @@ The **request scheduler** is used by `IAmACommandProcessor` when methods like `S Forward requests to Brighter for processing: ```csharp - public class CustomRequestHandler - { - private readonly IAmACommandProcessor _processor; - - public CustomRequestHandler(IAmACommandProcessor processor) - { - _processor = processor; - } + using System.Text.Json; + using System.Threading.Tasks; + using Paramore.Brighter; + using Paramore.Brighter.JsonConverters; + using Paramore.Brighter.Scheduler.Events; + public class CustomRequestHandler(IAmACommandProcessor processor) + { public async Task ExecuteAsync(CustomSchedulerObject obj) { - await _processor.SendAsync(new FireRequestMessage - { - RequestType = obj.Data.RequestType, - RequestData = obj.Data.Data, - Async = obj.Data.Async - }); + var fire = JsonSerializer.Deserialize(obj.Data, JsonSerialisationOptions.Options)!; + await processor.SendAsync(fire); } } ``` @@ -165,7 +206,7 @@ The **request scheduler** is used by `IAmACommandProcessor` when methods like `S - **Flow Preservation**: Ensure `Async = true`/`false` is set correctly to match the original call (sync/async). - **Serialization**: - Use a consistent format (e.g., JSON) for `RequestData` in `FireRequestMessage`. + Use a consistent format (e.g., JSON) for `RequestData` in `FireSchedulerRequest`. -**Note**: Replace `CustomSchedulerAPI`, `CustomSchedulerObject`, and serialization logic with your actual implementation details. +**Note**: Replace `CustomSchedulerAPI` and `CustomSchedulerObject` with your scheduler's own client and job types. diff --git a/contents/FAQ.md b/contents/FAQ.md index 665e4c9..a1dd8b8 100644 --- a/contents/FAQ.md +++ b/contents/FAQ.md @@ -122,6 +122,9 @@ public class MyHandler : RequestHandlerAsync In V10, configuration was simplified: + + + - **AddProducers()**: Configures message producers (sending messages to external bus). Replaces V9's `UseExternalBus()` - **AddConsumers()**: Configures message consumers (receiving messages from external bus). Replaces V9's `AddServiceActivator()` diff --git a/contents/HangfireScheduler.md b/contents/HangfireScheduler.md index bea6937..1765625 100644 --- a/contents/HangfireScheduler.md +++ b/contents/HangfireScheduler.md @@ -129,6 +129,10 @@ factory is constructed, as the complete examples below do. Configure Brighter with Hangfire scheduler: ```csharp +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.MessageScheduler.Hangfire; using Hangfire; @@ -163,7 +167,12 @@ builder.Services.AddBrighter(options => { options.HandlerLifetime = ServiceLifetime.Scoped; }) -.UseScheduler(new HangfireMessageSchedulerFactory()) +.UseScheduler(provider => +{ + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; +}) .AutoFromAssemblies(); var app = builder.Build(); @@ -177,6 +186,11 @@ app.Run(); ### Configuration with PostgreSQL ```csharp +using Hangfire; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; using Hangfire.PostgreSql; builder.Services.AddHangfire(configuration => configuration @@ -195,13 +209,24 @@ builder.Services.AddBrighter(options => { options.HandlerLifetime = ServiceLifetime.Scoped; }) -.UseScheduler(new HangfireMessageSchedulerFactory()) +.UseScheduler(provider => +{ + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; +}) .AutoFromAssemblies(); ``` ### Configuration with Redis ```csharp +using System; +using Hangfire; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; using Hangfire.Redis.StackExchange; builder.Services.AddHangfire(configuration => configuration @@ -223,7 +248,12 @@ builder.Services.AddBrighter(options => { options.HandlerLifetime = ServiceLifetime.Scoped; }) -.UseScheduler(new HangfireMessageSchedulerFactory()) +.UseScheduler(provider => +{ + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; +}) .AutoFromAssemblies(); ``` @@ -232,13 +262,20 @@ builder.Services.AddBrighter(options => Organize Brighter jobs into a specific queue: ```csharp +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; + builder.Services.AddBrighter(options => { options.HandlerLifetime = ServiceLifetime.Scoped; }) -.UseScheduler(new HangfireMessageSchedulerFactory +.UseScheduler(provider => { - Queue = "brighter-scheduler" // Custom queue for Brighter jobs + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client, Queue = "brighter-scheduler" }; // Custom queue for Brighter jobs }) .AutoFromAssemblies(); ``` @@ -255,6 +292,8 @@ Hangfire supports multiple persistent storage backends: ### SQL Server ```csharp +using System; +using Hangfire; using Hangfire.SqlServer; configuration.UseSqlServerStorage( @@ -273,18 +312,23 @@ configuration.UseSqlServerStorage( ### PostgreSQL ```csharp +using Hangfire; using Hangfire.PostgreSql; -configuration.UsePostgreSqlStorage(options => -{ - options.UseNpgsqlConnection(connectionString); - options.SchemaName = "hangfire"; // Optional custom schema -}); +configuration.UsePostgreSqlStorage( + options => options.UseNpgsqlConnection(connectionString), + new PostgreSqlStorageOptions + { + SchemaName = "hangfire" // Optional custom schema + }); ``` ### MySQL ```csharp +using System; +using System.Transactions; +using Hangfire; using Hangfire.MySql; configuration.UseStorage(new MySqlStorage( @@ -305,6 +349,8 @@ configuration.UseStorage(new MySqlStorage( ### Redis ```csharp +using System; +using Hangfire; using Hangfire.Redis.StackExchange; configuration.UseRedisStorage( @@ -320,6 +366,7 @@ configuration.UseRedisStorage( ### In-Memory (Development Only) ```csharp +using Hangfire; using Hangfire.MemoryStorage; configuration.UseMemoryStorage(); @@ -334,6 +381,8 @@ Hangfire includes a powerful web-based dashboard for monitoring jobs. ### Basic Dashboard Setup ```csharp +using Hangfire; + var app = builder.Build(); // Add dashboard at /hangfire @@ -347,8 +396,16 @@ app.Run(); Secure the dashboard with authorization: ```csharp +using Hangfire; using Hangfire.Dashboard; +// Apply filter +app.UseHangfireDashboard("/hangfire", new DashboardOptions +{ + Authorization = new[] { new HangfireAuthorizationFilter() }, + DashboardTitle = "Brighter Scheduler Jobs" +}); + public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter { public bool Authorize(DashboardContext context) @@ -359,18 +416,15 @@ public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter return httpContext.User.Identity?.IsAuthenticated ?? false; } } - -// Apply filter -app.UseHangfireDashboard("/hangfire", new DashboardOptions -{ - Authorization = new[] { new HangfireAuthorizationFilter() }, - DashboardTitle = "Brighter Scheduler Jobs" -}); ``` ### Dashboard with Custom Path and Options +This uses the `HangfireAuthorizationFilter` from the example above: + ```csharp +using Hangfire; + app.UseHangfireDashboard("/admin/jobs", new DashboardOptions { Authorization = new[] { new HangfireAuthorizationFilter() }, @@ -398,23 +452,25 @@ The Hangfire dashboard provides: ### Basic Scheduling ```csharp -public class OrderService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Paramore.Brighter; +public class OrderService(IAmACommandProcessor commandProcessor, IOrderRepository repository) +{ public async Task CreateOrder(Order order) { - await _repository.SaveAsync(order); + await repository.SaveAsync(order); // Schedule order processing for 1 hour later - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( TimeSpan.FromHours(1), new ProcessOrderCommand { OrderId = order.Id } ); // Store scheduler ID for potential cancellation order.ProcessSchedulerId = schedulerId; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); // Job will appear in Hangfire dashboard } @@ -429,26 +485,24 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Paramore.Brighter; -public class OrderService +public class OrderService( + IAmAMessageSchedulerAsync scheduler, IOrderRepository repository, ILogger logger) { - // ... _repository and _logger, injected as your application supplies them - private readonly IAmAMessageSchedulerAsync _scheduler; - public async Task CancelOrder(Guid orderId) { - var order = await _repository.GetAsync(orderId); + var order = await repository.GetAsync(orderId); // Cancel the scheduled processing if (!string.IsNullOrEmpty(order.ProcessSchedulerId)) { - await _scheduler.CancelAsync(order.ProcessSchedulerId); - _logger.LogInformation("Cancelled scheduled processing for order {OrderId}", orderId); + await scheduler.CancelAsync(order.ProcessSchedulerId); + logger.LogInformation("Cancelled scheduled processing for order {OrderId}", orderId); // Job will be removed from Hangfire dashboard } order.Status = OrderStatus.Cancelled; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); } } ``` @@ -456,21 +510,24 @@ public class OrderService ### Scheduling with Absolute Time ```csharp -public class ReportService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class ReportService(IAmACommandProcessor commandProcessor, ILogger logger) +{ public async Task ScheduleDailyReport() { // Schedule for tomorrow at 9 AM var tomorrow9AM = DateTimeOffset.UtcNow.Date.AddDays(1).AddHours(9); - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( tomorrow9AM, new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date } ); - _logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); + logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); // View in dashboard: Jobs -> Scheduled } @@ -482,6 +539,10 @@ public class ReportService Hangfire supports multiple server instances for high availability: ```csharp +using System; +using Hangfire; +using Microsoft.Extensions.DependencyInjection; + // Server 1, 2, 3... all with same configuration builder.Services.AddHangfire(configuration => configuration .UseSqlServerStorage(sharedConnectionString) // Shared database @@ -489,9 +550,9 @@ builder.Services.AddHangfire(configuration => configuration builder.Services.AddHangfireServer(options => { - options.ServerName = Environment.MachineName, // Unique server name - options.WorkerCount = Environment.ProcessorCount * 5, // Worker threads - options.Queues = new[] { "default", "brighter-scheduler" } // Process these queues + options.ServerName = Environment.MachineName; // Unique server name + options.WorkerCount = Environment.ProcessorCount * 5; // Worker threads + options.Queues = new[] { "default", "brighter-scheduler" }; // Process these queues }); ``` @@ -516,9 +577,15 @@ builder.Services.AddHangfireServer(options => Monitor job execution with filters: ```csharp +using Hangfire; using Hangfire.Common; using Hangfire.Server; using Hangfire.States; +using Hangfire.Storage; +using Microsoft.Extensions.Logging; + +// Register filter +GlobalJobFilters.Filters.Add(new BrighterJobLogFilter(loggerFactory.CreateLogger())); public class BrighterJobLogFilter : IServerFilter, IApplyStateFilter { @@ -563,9 +630,6 @@ public class BrighterJobLogFilter : IServerFilter, IApplyStateFilter // Optional: log state changes } } - -// Register filter -GlobalJobFilters.Filters.Add(new BrighterJobLogFilter(loggerFactory.CreateLogger())); ``` ### Health Checks @@ -573,6 +637,19 @@ GlobalJobFilters.Filters.Add(new BrighterJobLogFilter(loggerFactory.CreateLogger Monitor Hangfire health: ```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +// Register health check +builder.Services.AddHealthChecks() + .AddCheck("hangfire"); + public class HangfireHealthCheck : IHealthCheck { private readonly JobStorage _storage; @@ -615,10 +692,6 @@ public class HangfireHealthCheck : IHealthCheck } } } - -// Register health check -builder.Services.AddHealthChecks() - .AddCheck("hangfire"); ``` ## Hangfire Best Practices @@ -626,6 +699,9 @@ builder.Services.AddHealthChecks() ### 1. Always Use Persistent Storage in Production ```csharp +using Hangfire; +using Hangfire.MemoryStorage; + // Good - Persistent storage configuration.UseSqlServerStorage(connectionString); @@ -635,7 +711,11 @@ configuration.UseMemoryStorage(); ### 2. Secure the Dashboard +Protect it with an authorization filter, such as the `HangfireAuthorizationFilter` shown under the dashboard's configuration: + ```csharp +using Hangfire; + // Good - Require authentication app.UseHangfireDashboard("/hangfire", new DashboardOptions { @@ -649,23 +729,39 @@ app.UseHangfireDashboard(); // No authorization ### 3. Use Custom Queues for Organization ```csharp +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; + +// brighter is the builder that services.AddBrighter(...) returns + // Good - Separate queue for Brighter jobs -.UseScheduler(new HangfireMessageSchedulerFactory +brighter.UseScheduler(provider => { - Queue = "brighter-scheduler" -}) + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client, Queue = "brighter-scheduler" }; +}); // Consider - Everything in default queue (harder to manage) -.UseScheduler(new HangfireMessageSchedulerFactory()) +brighter.UseScheduler(provider => +{ + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; +}); ``` ### 4. Store Scheduler IDs for Cancellation ```csharp +// ... inside a service holding _commandProcessor and _repository, like OrderService above + // Good - Store ID for later cancellation var schedulerId = await _commandProcessor.SendAsync(delay, command); -entity.SchedulerId = schedulerId; -await _repository.SaveAsync(entity); +order.ProcessSchedulerId = schedulerId; +await _repository.SaveAsync(order); // Bad - Lost ability to cancel await _commandProcessor.SendAsync(delay, command); // ID discarded @@ -674,17 +770,18 @@ await _commandProcessor.SendAsync(delay, command); // ID discarded ### 5. Configure Appropriate Worker Counts ```csharp +using System; +using Hangfire; +using Microsoft.Extensions.DependencyInjection; + // Good - Based on workload builder.Services.AddHangfireServer(options => { options.WorkerCount = Environment.ProcessorCount * 5; // Adjust based on I/O vs CPU -}); - -// Avoid - Too many workers (resource exhaustion) -options.WorkerCount = 1000; -// Avoid - Too few workers (slow processing) -options.WorkerCount = 1; + // Avoid - Too many workers (resource exhaustion): options.WorkerCount = 1000; + // Avoid - Too few workers (slow processing): options.WorkerCount = 1; +}); ``` ### 6. Monitor the Dashboard Regularly @@ -700,8 +797,14 @@ options.WorkerCount = 1; ### 7. Handle Timezone Correctly ```csharp +using System; + // Good - Use UTC for consistency var scheduledTime = DateTimeOffset.UtcNow.AddHours(1); +``` + +```csharp +using System; // Bad - Local time (ambiguous across servers) var scheduledTime = DateTimeOffset.Now.AddHours(1); @@ -710,6 +813,10 @@ var scheduledTime = DateTimeOffset.Now.AddHours(1); ### 8. Configure Automatic Retry Policies ```csharp +using System; +using Hangfire; +using Microsoft.Extensions.DependencyInjection; + // Good - Automatic retry for transient failures builder.Services.AddHangfireServer(options => { @@ -734,6 +841,10 @@ builder.Services.AddHangfireServer(options => **Solutions:** ```csharp +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; + // Verify Hangfire server is started builder.Services.AddHangfireServer(); @@ -771,6 +882,9 @@ builder.Services.AddSingleton(); **Solutions:** ```csharp +using Hangfire; +using Hangfire.Dashboard; + // Ensure UseHangfireDashboard is called app.UseHangfireDashboard("/hangfire"); @@ -783,6 +897,12 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions { Authorization = new[] { new AllowAllDashboardAuthorizationFilter() } }); + +// Hangfire ships no allow-all filter; this is the whole of one +public class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) => true; +} ``` ## Related Documentation diff --git a/contents/InMemoryOptions.md b/contents/InMemoryOptions.md index c4a142a..4a482db 100644 --- a/contents/InMemoryOptions.md +++ b/contents/InMemoryOptions.md @@ -192,6 +192,7 @@ public class IntegrationTests : IDisposable Use InMemory components for development/testing, production components elsewhere: ```csharp +using Hangfire; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -262,7 +263,12 @@ public static class BrighterConfiguration } // Production: Quartz, Hangfire, AWS Scheduler, etc.; Hangfire's storage is configured with AddHangfire - return brighter.UseScheduler(new HangfireMessageSchedulerFactory()); + return brighter.UseScheduler(provider => + { + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; + }); } private static IAmAnOutbox GetOutbox( diff --git a/contents/InMemoryScheduler.md b/contents/InMemoryScheduler.md index 279fcd8..64fc0ab 100644 --- a/contents/InMemoryScheduler.md +++ b/contents/InMemoryScheduler.md @@ -65,12 +65,22 @@ Your Handler executes **Unit Testing:** ```csharp +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Xunit; + [Fact] public async Task Should_Schedule_Command_For_Later_Execution() { // Arrange var services = new ServiceCollection(); - services.AddBrighter(options => { ... }) + services.AddBrighter(options => + { + // ... + }) .UseScheduler(new InMemorySchedulerFactory()) // Perfect for tests .AutoFromAssemblies(); @@ -80,7 +90,7 @@ public async Task Should_Schedule_Command_For_Later_Execution() // Act var schedulerId = await commandProcessor.SendAsync( TimeSpan.FromMilliseconds(100), - new TestCommand(), + new TestCommand() ); // Assert @@ -102,19 +112,21 @@ The InMemory Scheduler might be acceptable in production **only if**: **Example - Acceptable Production Use:** ```csharp +using System; +using System.Threading.Tasks; +using Paramore.Brighter; + // Low-priority analytics events that can be lost -public class AnalyticsService +public class AnalyticsService(IAmACommandProcessor commandProcessor, IAnalyticsRepository repository) { - private readonly IAmACommandProcessor _commandProcessor; - public async Task TrackUserAction(string userId, string action) { // Track immediately - await _repository.SaveActionAsync(userId, action); + await repository.SaveActionAsync(userId, action); // Schedule low-priority aggregation (acceptable to lose) - await _commandProcessor.SendAsync( - TimeSpan.FromMinutes(5), + await commandProcessor.SendAsync( + TimeSpan.FromMinutes(5), new AggregateAnalyticsCommand { UserId = userId } ); } @@ -148,6 +160,8 @@ is a complete configuration; these four properties are what you can change about Configure the InMemory Scheduler with `InMemorySchedulerFactory`: ```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter; @@ -168,6 +182,7 @@ var app = builder.Build(); Use InMemory for development, production schedulers elsewhere: ```csharp +using Hangfire; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -191,7 +206,12 @@ if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Te else { // Production - use a durable scheduler; Hangfire's storage is configured with AddHangfire - brighter.UseScheduler(new HangfireMessageSchedulerFactory()); + brighter.UseScheduler(provider => + { + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; + }); } brighter.AutoFromAssemblies(); @@ -255,21 +275,25 @@ dotnet add package Paramore.Brighter.Extensions.DependencyInjection Schedule a command with a delay: ```csharp -public class OrderService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class OrderService( + IAmACommandProcessor commandProcessor, IOrderRepository repository, ILogger logger) +{ public async Task CreateOrder(Order order) { - await _repository.SaveAsync(order); + await repository.SaveAsync(order); // Schedule order confirmation email for 5 minutes later - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( TimeSpan.FromMinutes(5), new SendOrderConfirmationCommand { OrderId = order.Id } ); - _logger.LogInformation("Scheduled confirmation email: {SchedulerId}", schedulerId); + logger.LogInformation("Scheduled confirmation email: {SchedulerId}", schedulerId); } } ``` @@ -279,21 +303,24 @@ public class OrderService Schedule for a specific time: ```csharp -public class ReportService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class ReportService(IAmACommandProcessor commandProcessor, ILogger logger) +{ public async Task ScheduleDailyReport() { // Schedule for tomorrow at 9 AM var tomorrow9AM = DateTimeOffset.UtcNow.Date.AddDays(1).AddHours(9); - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( tomorrow9AM, new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date } ); - _logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); + logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); } } ``` @@ -308,45 +335,45 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Paramore.Brighter; -public class OrderService +public class OrderService( + IAmARequestSchedulerAsync scheduler, IOrderRepository repository, ILogger logger) { - // ... _repository and _logger, injected as your application supplies them - private readonly IAmARequestSchedulerAsync _scheduler; - public async Task CreateOrder(Order order) { // Schedule through the scheduler, so that it can cancel the request later - order.ConfirmationSchedulerId = await _scheduler.ScheduleAsync( + order.ConfirmationSchedulerId = await scheduler.ScheduleAsync( new SendOrderConfirmationCommand { OrderId = order.Id }, RequestSchedulerType.Send, TimeSpan.FromMinutes(5)); - await _repository.SaveAsync(order); + await repository.SaveAsync(order); } public async Task CancelOrder(Guid orderId) { - var order = await _repository.GetAsync(orderId); + var order = await repository.GetAsync(orderId); // Cancel the scheduled confirmation email if (!string.IsNullOrEmpty(order.ConfirmationSchedulerId)) { - await _scheduler.CancelAsync(order.ConfirmationSchedulerId); - _logger.LogInformation("Cancelled scheduled email for order {OrderId}", orderId); + await scheduler.CancelAsync(order.ConfirmationSchedulerId); + logger.LogInformation("Cancelled scheduled email for order {OrderId}", orderId); } order.Status = OrderStatus.Cancelled; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); } } ``` ### Testing with InMemory Scheduler -Example unit test using InMemory Scheduler: +Example unit test using InMemory Scheduler. `TestCommandHandler` records each command it handles in a `TestHandlerRegistry`, which the test registers as a singleton and then asks: ```csharp using System; +using System.Collections.Concurrent; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter; @@ -364,13 +391,14 @@ public class SchedulingTests : IDisposable var services = new ServiceCollection(); _handlerRegistry = new TestHandlerRegistry(); + services.AddSingleton(_handlerRegistry); + services.AddBrighter(options => { options.HandlerLifetime = ServiceLifetime.Scoped; }) .UseScheduler(new InMemorySchedulerFactory()) // InMemory for tests - .Handlers(_handlerRegistry) - .AutoFromAssemblies(); + .AsyncHandlers(registry => registry.RegisterAsync()); _serviceProvider = services.BuildServiceProvider(); _commandProcessor = _serviceProvider.GetRequiredService(); @@ -380,7 +408,7 @@ public class SchedulingTests : IDisposable public async Task Should_Execute_Scheduled_Command_After_Delay() { // Arrange - var command = new TestCommand { Id = Guid.NewGuid() }; + var command = new TestCommand(); var delay = TimeSpan.FromMilliseconds(100); // Act @@ -400,7 +428,7 @@ public class SchedulingTests : IDisposable public async Task Should_Cancel_Scheduled_Command() { // Arrange - var command = new TestCommand { Id = Guid.NewGuid() }; + var command = new TestCommand(); var delay = TimeSpan.FromSeconds(10); // Long delay var scheduler = _serviceProvider.GetRequiredService(); @@ -420,6 +448,24 @@ public class SchedulingTests : IDisposable _serviceProvider?.Dispose(); } } + +public class TestHandlerRegistry +{ + private readonly ConcurrentDictionary _handled = new(); + + public void Record(Id id) => _handled[id] = true; + public bool WasHandled(Id id) => _handled.ContainsKey(id); + public int HandledCount => _handled.Count; +} + +public class TestCommandHandler(TestHandlerRegistry registry) : RequestHandlerAsync +{ + public override Task HandleAsync(TestCommand command, CancellationToken cancellationToken = default) + { + registry.Record(command.Id); + return base.HandleAsync(command, cancellationToken); + } +} ``` ## InMemory Scheduler Best Practices @@ -427,16 +473,23 @@ public class SchedulingTests : IDisposable ### 1. Use for Testing Only ```csharp +using Microsoft.Extensions.Hosting; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + // Good - Environment-specific if (environment.IsDevelopment() || environment.IsEnvironment("Testing")) { - services.UseScheduler(new InMemorySchedulerFactory()); + brighter.UseScheduler(new InMemorySchedulerFactory()); } ``` ```csharp +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + // Bad - Always using InMemory in production -services.UseScheduler(new InMemorySchedulerFactory()); +brighter.UseScheduler(new InMemorySchedulerFactory()); ``` ### 2. Document Production Limitations @@ -444,10 +497,13 @@ services.UseScheduler(new InMemorySchedulerFactory()); If you use InMemory in production, document why: ```csharp +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + // PRODUCTION NOTE: Using InMemory scheduler because loss of // scheduled analytics aggregations is acceptable. These are // non-critical and will be regenerated on next sync. -services.UseScheduler(new InMemorySchedulerFactory()); +brighter.UseScheduler(new InMemorySchedulerFactory()); ``` ### 3. Keep Delays Short @@ -455,6 +511,8 @@ services.UseScheduler(new InMemorySchedulerFactory()); If using in production, keep delays under a few minutes: ```csharp +using System; + // Good - Short delay acceptable to lose await _commandProcessor.SendAsync(TimeSpan.FromMinutes(2), command); @@ -464,9 +522,14 @@ await _commandProcessor.SendAsync(TimeSpan.FromHours(24), command); ### 4. Test Scheduler Behavior -Write tests that verify scheduled behavior: +Write tests that verify scheduled behavior. This one is another test method for the `SchedulingTests` class above, and uses its fields: ```csharp +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + [Fact] public async Task Should_Handle_Concurrent_Scheduled_Commands() { @@ -476,7 +539,7 @@ public async Task Should_Handle_Concurrent_Scheduled_Commands() { ids.Add(await _commandProcessor.SendAsync( TimeSpan.FromMilliseconds(50 + i * 10), - new TestCommand { Number = i } + new TestCommand() )); } @@ -491,6 +554,8 @@ public async Task Should_Handle_Concurrent_Scheduled_Commands() ### 5. Don't Rely on It for Critical Work ```csharp +using System; + // Bad - Critical payment processing await _commandProcessor.SendAsync( TimeSpan.FromMinutes(5), @@ -519,6 +584,11 @@ await _commandProcessor.SendAsync( **Solution**: ```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Xunit; + // Add logging to verify scheduling var schedulerId = await _commandProcessor.SendAsync(delay, command); _logger.LogInformation("Scheduled job {SchedulerId} for {Delay}", schedulerId, delay); diff --git a/contents/QuartzScheduler.md b/contents/QuartzScheduler.md index f885f69..2593bf1 100644 --- a/contents/QuartzScheduler.md +++ b/contents/QuartzScheduler.md @@ -91,6 +91,8 @@ AWS and InMemory factories; the request hook carries the same name on all three. Configure Brighter with Quartz scheduler: ```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.MessageScheduler.Quartz; using Quartz; @@ -137,6 +139,13 @@ var app = builder.Build(); For production, use a persistent job store: ```csharp +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + builder.Services.AddQuartz(q => { q.SchedulerId = "BrighterScheduler"; @@ -151,14 +160,14 @@ builder.Services.AddQuartz(q => sqlServer.ConnectionString = builder.Configuration.GetConnectionString("Quartz"); sqlServer.TablePrefix = "QRTZ_"; }); - store.UseJsonSerializer(); // Use JSON serialization for job data - }); + store.UseNewtonsoftJsonSerializer(); // Use JSON serialization for job data - // Optional: Enable clustering for HA - q.UseClustering(clustering => - { - clustering.CheckinInterval = TimeSpan.FromSeconds(20); - clustering.CheckinMisfireThreshold = TimeSpan.FromSeconds(30); + // Optional: Enable clustering for HA + store.UseClustering(clustering => + { + clustering.CheckinInterval = TimeSpan.FromSeconds(20); + clustering.CheckinMisfireThreshold = TimeSpan.FromSeconds(30); + }); }); // Use Microsoft DI for job creation @@ -214,13 +223,16 @@ You can configure Quartz via appsettings.json: Then configure with: ```csharp +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Quartz; + builder.Services.AddQuartz(q => { q.UseMicrosoftDependencyInjectionJobFactory(); // Load configuration from appsettings var quartzConfig = builder.Configuration.GetSection("Quartz"); - q.UseProperties = true; // Apply configuration foreach (var item in quartzConfig.AsEnumerable()) @@ -240,6 +252,8 @@ Quartz supports multiple persistent job stores: ### SQL Server ```csharp +using Quartz; + q.UsePersistentStore(store => { store.UseSqlServer(sqlServer => @@ -247,7 +261,7 @@ q.UsePersistentStore(store => sqlServer.ConnectionString = connectionString; sqlServer.TablePrefix = "QRTZ_"; }); - store.UseJsonSerializer(); + store.UseNewtonsoftJsonSerializer(); }); ``` @@ -256,6 +270,8 @@ q.UsePersistentStore(store => ### PostgreSQL ```csharp +using Quartz; + q.UsePersistentStore(store => { store.UsePostgres(postgres => @@ -263,7 +279,7 @@ q.UsePersistentStore(store => postgres.ConnectionString = connectionString; postgres.TablePrefix = "qrtz_"; }); - store.UseJsonSerializer(); + store.UseNewtonsoftJsonSerializer(); }); ``` @@ -272,6 +288,8 @@ q.UsePersistentStore(store => ### MySQL ```csharp +using Quartz; + q.UsePersistentStore(store => { store.UseMySql(mysql => @@ -279,7 +297,7 @@ q.UsePersistentStore(store => mysql.ConnectionString = connectionString; mysql.TablePrefix = "QRTZ_"; }); - store.UseJsonSerializer(); + store.UseNewtonsoftJsonSerializer(); }); ``` @@ -290,6 +308,8 @@ q.UsePersistentStore(store => For development, you can use the in-memory store (no persistence): ```csharp +using Quartz; + // No UsePersistentStore call needed - in-memory is default q.UseMicrosoftDependencyInjectionJobFactory(); ``` @@ -303,7 +323,16 @@ q.UseMicrosoftDependencyInjectionJobFactory(); By default, Brighter uses `Guid.NewGuid()` for scheduler IDs. Customize this behavior: ```csharp -builder.Services.UseScheduler(provider => +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + +builder.Services.AddBrighter(options => +{ + // ... +}) +.UseScheduler(provider => { var schedulerFactory = provider.GetRequiredService(); var scheduler = schedulerFactory.GetScheduler().GetAwaiter().GetResult(); @@ -311,7 +340,7 @@ builder.Services.UseScheduler(provider => return new QuartzSchedulerFactory(scheduler) { // Customize message scheduler ID - GetOrCreateMessageSchedulerId = message => message.Id, + GetOrCreateSchedulerId = message => message.Id.Value, // Customize request scheduler ID GetOrCreateRequestSchedulerId = request => @@ -323,7 +352,7 @@ builder.Services.UseScheduler(provider => return request.Id.ToString(); } }; -}) +}); ``` **Why customize?** @@ -337,7 +366,9 @@ builder.Services.UseScheduler(provider => Organize jobs into groups: ```csharp -return new QuartzSchedulerFactory(scheduler) +using Paramore.Brighter.MessageScheduler.Quartz; + +var schedulerFactory = new QuartzSchedulerFactory(scheduler) { Group = "BrighterJobs" // All Brighter jobs in this group }; @@ -354,12 +385,13 @@ return new QuartzSchedulerFactory(scheduler) Configure how Quartz handles missed triggers: ```csharp +using System; +using Microsoft.Extensions.DependencyInjection; +using Quartz; + builder.Services.AddQuartz(q => { - q.UseMisfire​Handler(options => - { - options.MisfireThreshold = TimeSpan.FromMinutes(1); - }); + q.MisfireThreshold = TimeSpan.FromMinutes(1); // Additional Quartz configuration... }); @@ -370,23 +402,25 @@ builder.Services.AddQuartz(q => ### Basic Scheduling ```csharp -public class OrderService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Paramore.Brighter; +public class OrderService(IAmACommandProcessor commandProcessor, IOrderRepository repository) +{ public async Task CreateOrder(Order order) { - await _repository.SaveAsync(order); + await repository.SaveAsync(order); // Schedule order processing for 1 hour later - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( TimeSpan.FromHours(1), new ProcessOrderCommand { OrderId = order.Id } ); // Store scheduler ID for potential cancellation order.ProcessSchedulerId = schedulerId; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); } } ``` @@ -399,24 +433,22 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Paramore.Brighter; -public class OrderService +public class OrderService( + IAmAMessageSchedulerAsync scheduler, IOrderRepository repository, ILogger logger) { - // ... _repository and _logger, injected as your application supplies them - private readonly IAmAMessageSchedulerAsync _scheduler; - public async Task CancelOrder(Guid orderId) { - var order = await _repository.GetAsync(orderId); + var order = await repository.GetAsync(orderId); // Cancel the scheduled processing if (!string.IsNullOrEmpty(order.ProcessSchedulerId)) { - await _scheduler.CancelAsync(order.ProcessSchedulerId); - _logger.LogInformation("Cancelled scheduled processing for order {OrderId}", orderId); + await scheduler.CancelAsync(order.ProcessSchedulerId); + logger.LogInformation("Cancelled scheduled processing for order {OrderId}", orderId); } order.Status = OrderStatus.Cancelled; - await _repository.UpdateAsync(order); + await repository.UpdateAsync(order); } } ``` @@ -424,21 +456,24 @@ public class OrderService ### Scheduling with Absolute Time ```csharp -public class ReportService -{ - private readonly IAmACommandProcessor _commandProcessor; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +public class ReportService(IAmACommandProcessor commandProcessor, ILogger logger) +{ public async Task ScheduleDailyReport() { // Schedule for tomorrow at 9 AM var tomorrow9AM = DateTimeOffset.UtcNow.Date.AddDays(1).AddHours(9); - var schedulerId = await _commandProcessor.SendAsync( + var schedulerId = await commandProcessor.SendAsync( tomorrow9AM, new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date } ); - _logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); + logger.LogInformation("Scheduled daily report: {SchedulerId}", schedulerId); } } ``` @@ -448,6 +483,10 @@ public class ReportService Quartz supports clustering for high availability: ```csharp +using System; +using Microsoft.Extensions.DependencyInjection; +using Quartz; + builder.Services.AddQuartz(q => { q.SchedulerId = "AUTO"; // Each instance gets unique ID @@ -458,14 +497,14 @@ builder.Services.AddQuartz(q => { sqlServer.ConnectionString = connectionString; }); - store.UseJsonSerializer(); - }); + store.UseNewtonsoftJsonSerializer(); - // Enable clustering - q.UseClustering(clustering => - { - clustering.CheckinInterval = TimeSpan.FromSeconds(20); - clustering.CheckinMisfireThreshold = TimeSpan.FromSeconds(30); + // Enable clustering + store.UseClustering(clustering => + { + clustering.CheckinInterval = TimeSpan.FromSeconds(20); + clustering.CheckinMisfireThreshold = TimeSpan.FromSeconds(30); + }); }); }); ``` @@ -493,6 +532,19 @@ builder.Services.AddQuartz(q => Monitor job execution with listeners: ```csharp +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; + +// Register listener +builder.Services.AddQuartz(q => +{ + q.AddJobListener(); + // ... other configuration +}); + public class BrighterJobListener : IJobListener { private readonly ILogger _logger; @@ -529,13 +581,6 @@ public class BrighterJobListener : IJobListener return Task.CompletedTask; } } - -// Register listener -builder.Services.AddQuartz(q => -{ - q.AddJobListener(); - // ... other configuration -}); ``` ### Health Checks @@ -543,6 +588,18 @@ builder.Services.AddQuartz(q => Monitor Quartz scheduler health: ```csharp +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Quartz; + +// Register health check +builder.Services.AddHealthChecks() + .AddCheck("quartz"); + public class QuartzHealthCheck : IHealthCheck { private readonly ISchedulerFactory _schedulerFactory; @@ -582,10 +639,6 @@ public class QuartzHealthCheck : IHealthCheck } } } - -// Register health check -builder.Services.AddHealthChecks() - .AddCheck("quartz"); ``` ## Quartz Best Practices @@ -593,11 +646,13 @@ builder.Services.AddHealthChecks() ### 1. Always Use Persistent Job Store in Production ```csharp +using Quartz; + // Good - Persistent store q.UsePersistentStore(store => { store.UseSqlServer(connectionString); - store.UseJsonSerializer(); + store.UseNewtonsoftJsonSerializer(); }); // Bad - In-memory store in production @@ -607,8 +662,11 @@ q.UsePersistentStore(store => ### 2. Enable Clustering for High Availability ```csharp -// Good - Clustered for HA -q.UseClustering(clustering => +using System; +using Quartz; + +// Good - Clustered for HA, on the persistent store +store.UseClustering(clustering => { clustering.CheckinInterval = TimeSpan.FromSeconds(20); }); @@ -620,10 +678,12 @@ q.UseClustering(clustering => ### 3. Store Scheduler IDs for Cancellation ```csharp +// ... inside a service holding _commandProcessor and _repository, like OrderService above + // Good - Store ID for later cancellation var schedulerId = await _commandProcessor.SendAsync(delay, command); -entity.SchedulerId = schedulerId; -await _repository.SaveAsync(entity); +order.ProcessSchedulerId = schedulerId; +await _repository.SaveAsync(order); // Bad - Lost ability to cancel await _commandProcessor.SendAsync(delay, command); // ID discarded @@ -632,8 +692,10 @@ await _commandProcessor.SendAsync(delay, command); // ID discarded ### 4. Use JSON Serialization ```csharp +using Quartz; + // Good - JSON serialization (more portable) -store.UseJsonSerializer(); +store.UseNewtonsoftJsonSerializer(); // Avoid - Binary serialization (versioning issues) store.UseBinarySerializer(); @@ -642,23 +704,27 @@ store.UseBinarySerializer(); ### 5. Configure Appropriate Check-In Intervals ```csharp +using System; +using Quartz; + // Good - Balanced check-in -q.UseClustering(clustering => +store.UseClustering(clustering => { clustering.CheckinInterval = TimeSpan.FromSeconds(20); clustering.CheckinMisfireThreshold = TimeSpan.FromSeconds(30); -}); - -// Avoid - Too frequent (database load) -clustering.CheckinInterval = TimeSpan.FromSeconds(1); -// Avoid - Too infrequent (slow failover) -clustering.CheckinInterval = TimeSpan.FromMinutes(5); + // Avoid - Too frequent (database load): clustering.CheckinInterval = TimeSpan.FromSeconds(1); + // Avoid - Too infrequent (slow failover): clustering.CheckinInterval = TimeSpan.FromMinutes(5); +}); ``` ### 6. Monitor Job Execution +Register a job listener, such as the `BrighterJobListener` shown under monitoring above, and a trigger listener (an `ITriggerListener`) written the same way: + ```csharp +using Quartz; + // Good - Add listeners for monitoring q.AddJobListener(); q.AddTriggerListener(); @@ -667,8 +733,14 @@ q.AddTriggerListener(); ### 7. Handle Timezone Correctly ```csharp +using System; + // Good - Use UTC for consistency var scheduledTime = DateTimeOffset.UtcNow.AddHours(1); +``` + +```csharp +using System; // Bad - Local time (ambiguous across instances) var scheduledTime = DateTimeOffset.Now.AddHours(1); @@ -677,8 +749,10 @@ var scheduledTime = DateTimeOffset.Now.AddHours(1); ### 8. Set Appropriate Connection Pool Size ```csharp +using Quartz; + // For high-volume systems, increase connection pool -"Server=localhost;Database=Quartz;Max Pool Size=50;..." +q.UsePersistentStore(store => store.UseSqlServer("Server=localhost;Database=Quartz;Max Pool Size=50;...")); ``` ## Quartz Troubleshooting @@ -697,6 +771,9 @@ var scheduledTime = DateTimeOffset.Now.AddHours(1); **Solutions**: ```csharp +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; + // Verify scheduler is started var scheduler = await _schedulerFactory.GetScheduler(); var isStarted = scheduler.IsStarted; @@ -717,8 +794,10 @@ services.AddSingleton(); **Solution**: ```csharp -// Enable clustering -q.UseClustering(); +using Quartz; + +// Enable clustering, on the persistent store +store.UseClustering(); // Ensure clocks are synchronized (use NTP) // Check for clock drift across instances @@ -732,11 +811,14 @@ q.UseClustering(); **Solution**: ```csharp +using System; +using Quartz; + // Increase connection pool size -"Server=localhost;Database=Quartz;Max Pool Size=100;..." +var connectionString = "Server=localhost;Database=Quartz;Max Pool Size=100;..."; // Reduce clustering check-in frequency -clustering.CheckinInterval = TimeSpan.FromSeconds(30); +store.UseClustering(clustering => clustering.CheckinInterval = TimeSpan.FromSeconds(30)); // Consider database-specific optimizations // (indexes, isolation levels, etc.) diff --git a/contents/SchedulingAMessage.md b/contents/SchedulingAMessage.md index a384ec9..713351a 100644 --- a/contents/SchedulingAMessage.md +++ b/contents/SchedulingAMessage.md @@ -235,6 +235,7 @@ The examples below use `AutoFromAssemblies()`, and they work as shown once you a ### Configuring with Hangfire ```csharp +using Hangfire; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.MessageScheduler.Hangfire; @@ -244,7 +245,12 @@ services.AddBrighter(options => options.HandlerLifetime = ServiceLifetime.Scoped; }) // Hangfire's own storage is configured with AddHangfire, not on the factory -.UseScheduler(new HangfireMessageSchedulerFactory()) +.UseScheduler(provider => +{ + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; +}) .AutoFromAssemblies(); ``` diff --git a/contents/SwitchingSchedulers.md b/contents/SwitchingSchedulers.md index c36221c..164a20c 100644 --- a/contents/SwitchingSchedulers.md +++ b/contents/SwitchingSchedulers.md @@ -26,8 +26,13 @@ Your handlers, your commands and your calls to `SendAsync`, `PostAsync` and `Pub Before, on the InMemory scheduler: ```csharp -// ... -services.AddBrighter(options => { ... }) +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +services.AddBrighter(options => + { + // ... + }) .UseScheduler(new InMemorySchedulerFactory()) .AutoFromAssemblies(); ``` @@ -35,9 +40,16 @@ services.AddBrighter(options => { ... }) Before, on Quartz: ```csharp -// ... +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + // Before (Quartz) -services.AddBrighter(options => { ... }) +services.AddBrighter(options => + { + // ... + }) .UseScheduler(provider => { var schedulerFactory = provider.GetRequiredService(); @@ -57,13 +69,25 @@ What follows replaces the `UseScheduler` call, and nothing else. Hangfire needs its own storage, its server, and the job type Brighter schedules against, registered alongside the factory: ```csharp -// ... +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; + services.AddHangfire(config => config.UseSqlServerStorage(connectionString)); services.AddHangfireServer(); services.AddSingleton(); -services.AddBrighter(options => { ... }) - .UseScheduler(new HangfireMessageSchedulerFactory()) +services.AddBrighter(options => + { + // ... + }) + .UseScheduler(provider => + { + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; + }) .AutoFromAssemblies(); ``` @@ -72,8 +96,15 @@ services.AddBrighter(options => { ... }) Quartz supplies its own `IScheduler`, which `QuartzSchedulerFactory` wraps, so the factory is resolved from the service provider rather than constructed directly: ```csharp -// ... -services.AddBrighter(options => { ... }) +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + +services.AddBrighter(options => + { + // ... + }) .UseScheduler(provider => { var factory = provider.GetRequiredService(); @@ -86,9 +117,15 @@ services.AddBrighter(options => { ... }) ### Switching to AWS Scheduler ```csharp -// ... +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.AWS; + // After (Production on AWS) -services.AddBrighter(options => { ... }) +services.AddBrighter(options => + { + // ... + }) .UseScheduler(new AwsSchedulerFactory(awsConnection, "scheduler-role") { SchedulerTopicOrQueue = new RoutingKey("scheduler-topic"), @@ -107,9 +144,15 @@ services.AddBrighter(options => { ... }) ### Switching to Azure Service Bus ```csharp -// ... +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Azure; + // After (Production on Azure) -services.AddBrighter(options => { ... }) +services.AddBrighter(options => + { + // ... + }) .UseScheduler(new AzureServiceBusSchedulerFactory( clientProvider, new RoutingKey("brighter-scheduler-topic") @@ -141,23 +184,39 @@ services.AddBrighter(options => { ... }) You can run both schedulers during transition, choosing between them at startup, so the change can be rolled forward and back without a redeployment: ```csharp -// ... +using Hangfire; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + // Run both schedulers temporarily -services.AddBrighter(options => { ... }) - .UseScheduler(provider => +var brighter = services.AddBrighter(options => { - // Choose based on feature flag or configuration - if (Configuration.GetValue("UseQuartz")) - { - var factory = provider.GetRequiredService(); - return new QuartzSchedulerFactory(factory.GetScheduler().Result); - } - else - { - return new HangfireMessageSchedulerFactory(); - } + // ... }) .AutoFromAssemblies(); + +// Choose based on feature flag or configuration +if (configuration.GetValue("UseQuartz")) +{ + brighter.UseScheduler(provider => + { + var factory = provider.GetRequiredService(); + return new QuartzSchedulerFactory(factory.GetScheduler().GetAwaiter().GetResult()); + }); +} +else +{ + brighter.UseScheduler(provider => + { + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; + }); +} ``` Reverse the condition to migrate the other way. diff --git a/contents/V10MigrationGuide.md b/contents/V10MigrationGuide.md index cf4ce4f..ce6353b 100644 --- a/contents/V10MigrationGuide.md +++ b/contents/V10MigrationGuide.md @@ -163,6 +163,9 @@ public class PersonCreatedMapper : IAmAMessageMapper **Breaking Change**: Builder methods renamed for clarity. + + + **Before (V9)**: ```csharp diff --git a/spec/018-compile_residual/design.md b/spec/018-compile_residual/design.md index dbad0d9..53c5af4 100644 --- a/spec/018-compile_residual/design.md +++ b/spec/018-compile_residual/design.md @@ -101,7 +101,7 @@ and AC7 reconciles the two. | **fragment**: `CS0161`, `CS0825`, `CS0116` and kin | **14** | 017: make whole, or skip with an accepted reason | | **collision?**: `Task`, `Tag`, `User`, `Activity` and kin, a page type named like a pinned one | **7** | a page type, read by the receiver-aware resolution (D1) | | **pin-gap**: Shouldly's `ShouldBe…`, not pinned | **6** | all on `TestDoubleOptions.md`; `Shouldly` is pinned (D4) | -| **forthcoming**: `OnceOnlyAction.Replay`, on `master` and not in 10.7.0 | **4** | SKIPPED, *forthcoming* (D3) | +| **forthcoming**: `OnceOnlyAction.Replay`, on `master` and not in 10.7.0 | **4** | SKIPPED, *forthcoming* (D3). It shipped in **10.8.0**, so at that pin the four are *page-type* and repaired as such (`tasks.md` § *Phase 3 as executed*) | | unexplained | **1** | read at the phase | | PARSE (145) | Blocks | Repair rule | @@ -439,7 +439,7 @@ runs in every tranche PR"*, is replaced by rule 8 the gate, which now runs on ev | Case | Blocks (E2/E5) | Rule | Ruling | |---|---:|---|---| -| A forthcoming API, which the page already says ships after the pinned release | 4 | SKIPPED, reason *"forthcoming: ships after Brighter 10.7.0, as the page says at line N"* | **D3** | +| A forthcoming API, which the page already says ships after the pinned release | 4 at 10.7.0; **0** at 10.8.0 | SKIPPED, reason *"forthcoming: ships after Brighter 10.7.0, as the page says at line N"* | **D3** | | A single option, argument or expression shown alone, with no enclosing call on the page | ≤ 64 | SKIPPED, reason *"a single option shown alone; its type is named in the sentence before it"*, where that type resolves in the pin | **D3** | | A handler method shown without its class | 32 | built by the handler wrapper | **D2** | | Shouldly assertions | 6 | `Shouldly` **4.3.0** pinned in `refs.csproj`, the version Brighter's own tests use (`Directory.Packages.props:150` at 10.7.0). The page calls `ShouldBe…` and never names the package (`grep -c Shouldly contents/TestDoubleOptions.md` → **0**), so its repair adds `using Shouldly;` to each block and one sentence naming the package | **D4** | @@ -477,7 +477,7 @@ with the command beside it. same way and beat it, because hard repairs and recurrences added blocks a reachable count cannot see - **Pages with nothing BUILT: 39 → ≤ 14.** Three of the 39 are tranche pages: - `ReturningResultsFromAHandler.md`, `ReplayOnSeenReference.md` (forthcoming, D3) and + `ReturningResultsFromAHandler.md`, `ReplayOnSeenReference.md` (forthcoming at 10.7.0, D3; shipped in 10.8.0) and `DynamoDbDistributedLock.md`, which the V4 pin can reach (below). None is counted on. Of the 36 off-tranche pages, 28 have a reachable block. 39 − 25 = 14, so **25 of those 28** must gain a BUILT block. The eight with nothing reachable are not counted on @@ -528,14 +528,14 @@ cited from its rows, not restated. | shape / redirects / `--verify` | **unmoved** | **unmoved** | **unmoved** | no `SUMMARY.md` change | | `versioncheck` | **unmoved** | **unmoved** | **unmoved** | no tutorial pin touched. A repair that adds one is predicted in its phase | | `optioncheck` | **unmoved** | **unmoved** | **unmoved** | no option table touched | -| `symbolcheck` | **unmoved** | **unmoved** | phase 3: **22 → 23 entries, 3 → 8 silenced** (E6, D5); otherwise unmoved | the `AddServiceActivator` row and its opt-outs | +| `symbolcheck` | **unmoved** | **unmoved** | phase 3: **22 → 24 entries, 3 → 14 silenced** (E6, D5, D10); otherwise unmoved | the `AddServiceActivator` and `UseExternalBus` rows and their opt-outs | | `blockcheck` corpus | **unmoved, 990** | **unmoved** | **moves both ways**: retags (−3 for E2's JSON) and fence splits (+ for E2's 15 two-snippet and 7 mixed blocks); predicted per phase | a split adds a block | | `blockcheck` BUILT | **unmoved, 299** | **rises by the blocks the handler wrapper (D2), the `Program` fix (#75) and the Shouldly pin (D4) make build**, measured by phase 2's first task before any row is written: **+4**, all from the `Program` fix; the wrapper and Shouldly make none build alone (`tasks.md` § *Phase 2 as executed*) | **rises**, to ≥ 560 by the close | | | `blockcheck` scope lines | unmoved | **+1 line**, the V4 pin's assemblies; the main pin's assemblies **rise** by Shouldly's (D4); the scaffold rule also checks the `v4` column | units rise with each phase's stubs | | ## Design Decisions -D1–D5 were ruled by the maintainer on 2026-10-04, each as recommended. D6 and D7 needed no ruling. D8 and D9 were ruled at phase 2, on 2026-10-05 and 2026-10-09. +D1–D5 were ruled by the maintainer on 2026-10-04, each as recommended. D6 and D7 needed no ruling. D8 and D9 were ruled at phase 2, on 2026-10-05 and 2026-10-09. D10–D12 were ruled at phase 3, on 2026-10-13. | # | Question | Decision | Ruled | |---|---|---|---| @@ -548,6 +548,9 @@ D1–D5 were ruled by the maintainer on 2026-10-04, each as recommended. D6 and | **D7** | **Raise P1-3, the `net10.0` measurement, to P0?** | **No, but run it in phase 2 before the tables are drawn**, as friction #76 asks. Raise it if it moves a verdict | — | | **D8** | **Tiered resolution and page evidence?** Task 2.3's red-proof found D1's two checks let `Order` resolve to `StackExchange.Redis` on 20 rows (pages that use `Order` without declaring it), and `Context` to `Polly` on 11 | **Yes, both.** Tiered alone moves Polly 17 → 10 and Redis not at all. With page evidence, Redis → **0**, Polly → 6 (all on pages that import Polly), `built-by-using` unmoved at 32 | **Yes**, the maintainer, 2026-10-05 | | **D9** | **Wrap handlers shown by their `Fallback` alone?** Task 2.5 found five `members` blocks (`PolicyFallback.md` #6, #7, #9; `QueryPipeline.md` #24, #25) overriding only `Fallback` or `FallbackAsync`, outside D2's four methods, each carrying `wrapper:Context` | **Yes.** The return type picks the product: the request type, or a `Task` of it, means Brighter; anything else means Darker. The Holder is abstract, so a Darker `Fallback` shown alone is not failed for the `Execute` it does not show | **Yes**, the maintainer, 2026-10-09 | +| **D10** | **Watch `UseExternalBus` as well?** Task 3.1 found it chained after `AddServiceActivator` at both of D5's sites, and dead the same way at 10.8.0 (`git -C ../Brighter grep -lw UseExternalBus 10.8.0 -- 'src/*.cs'` → one file, a doc comment). Repairing the sites to `AddProducers` is obligation 21 and needs no ruling; the watchlist row is scope | **Yes.** Same evidence, same two sites, same two V9 discussion pages. A trial run with both rows: **24 entries**, `UseExternalBus` at **8 sites on 4 pages**, of which 6 are discussion lines on `FAQ.md` and `V10MigrationGuide.md`. With both opt-outs, **14 silenced** | **Yes**, the maintainer, 2026-10-13 | +| **D11** | **`symbolcheck --verify-list` counts a doc comment as a use.** Task 3.4 found both new rows LIVE: `AddServiceActivator` in 2 files and `UseExternalBus` in 1 at both refs, every hit a `///`. CI runs `--verify-list`, so the rows would fail the build. E6 ran only the plain gate | **Strip comments and strings in `resolve()`**: `git grep -l` still finds the candidates, and each is re-read at its ref through `strip_noncode`, the census's rule. Stripping can only lower a count, so no DEAD row can flip, and a live API keeps its declaration outside any comment. Red-proof in `tasks.md` § *Phase 3 as executed* | **Yes**, the maintainer, 2026-10-13 | +| **D12** | **Pin `Quartz.Serialization.Json`?** `QuartzScheduler.md` tells its reader to `dotnet add package Quartz.Serialization.Json` and calls the job store's JSON serializer in 7 blocks; the pin lacked the package, so all 7 failed on it. At 3.22.4, the version Brighter 10.8.0's Quartz dependency implies, `UseJsonSerializer` is `[Obsolete]` for `UseNewtonsoftJsonSerializer`, so the page is repaired either way (ledger 12) | **Yes**, as D4 pinned Shouldly: one package the page's reader installs. `Quartz.Serialization.Json` **3.22.4** in `refs.csproj` and `refs-v4.csproj`; each pin 551 → **553** reference assemblies | **Yes**, the maintainer, 2026-10-13 | ## What the Design Review Found — 2026-10-05 diff --git a/spec/018-compile_residual/probe/page.py b/spec/018-compile_residual/probe/page.py new file mode 100644 index 0000000..0843131 --- /dev/null +++ b/spec/018-compile_residual/probe/page.py @@ -0,0 +1,53 @@ +"""The verdict of every C# block on the pages named, and the full diagnostics of each FAILED one. + +The repair loop for phases 3–7: `--report` compiles the whole corpus to answer for one page, and +`--classify` prints only what failed. This stages the pages named, each against its own pin and +with its scaffold unit, exactly as `--report` does (`stage_by_pin`), and asks the gate's Roslyn tool +for verdicts and then for the errors. It is a view, not a gate: the unit rule and the baseline are +`--report`'s, and the phase close runs that. + +Usage, from the repo root, after the builds in tools/README.md row 9: + python3 spec/018-compile_residual/probe/page.py ... +Prints page #n VERDICT and, under a FAILED block, its code line:col message lines. +""" +import os, shutil, subprocess, sys, tempfile +sys.path.insert(0, 'tools') +import blockcheck as bc + +wanted = {os.path.relpath(os.path.abspath(p), bc.ROOT) for p in sys.argv[1:]} +if not wanted: + sys.exit('usage: page.py ...') +blocks = [b for b in bc.enumerate_blocks() if b.rel in wanted] +missing = wanted - {b.rel for b in blocks} +if missing: + sys.exit(f'no C# blocks on {", ".join(sorted(missing))}') + +def tool(*argv): + run = subprocess.run(['dotnet', bc.TOOL_DLL, *argv], capture_output=True, text=True) + if run.returncode != 0: + sys.exit(run.stderr) + return [l for l in run.stdout.split('\n') if l] + +staged = tempfile.mkdtemp(prefix='page-') +try: + groups = bc.stage_by_pin(blocks, staged) + if groups is None: + sys.exit('staging failed') + verdict, errors = {}, {} + for pin, (directory, refs, group) in groups.items(): + for line in tool(directory, refs): + ident, v = line.split('\t')[:2] + verdict[ident] = v + failed = [b.ident for b in group if verdict.get(b.ident) == 'FAILED'] + if failed: + for line in tool('--explain', directory, refs, *failed): + f = line.split('\t', 3) + errors.setdefault(f[0], []).append(f'{f[1]} {f[2]} {f[3]}') +finally: + shutil.rmtree(staged, ignore_errors=True) + +for b in sorted(blocks, key=lambda b: (b.rel, b.ordinal)): + v = 'SKIPPED' if b.skip_reason else verdict.get(b.ident, '?') + print(f'{os.path.basename(b.rel)} #{b.ordinal} {v}' + (f' ({b.skip_reason})' if b.skip_reason else '')) + for e in errors.get(b.ident, []): + print(f' {e}') diff --git a/spec/018-compile_residual/probe/s1/azure-config/Program.cs b/spec/018-compile_residual/probe/s1/azure-config/Program.cs new file mode 100644 index 0000000..acfb2d1 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/azure-config/Program.cs @@ -0,0 +1,49 @@ +// Task 3.5, AzureScheduler.md. Claims: +// block 21 DeadLetteringOnMessageExpiration = true and MaxDeliveryCount = 5 are the defaults +// block 25 LockDuration set on the configuration is the subscription's lock duration +// Case: the subscription built as the block builds it. Control: the same subscription with no +// configuration passed, whose values must be the defaults; for block 21 they must equal the case. +using System; +using Paramore.Brighter; +using Paramore.Brighter.MessageScheduler.Azure; +using Paramore.Brighter.MessagingGateway.AzureServiceBus; + +var failures = 0; +void Check(string what, bool ok) { Console.WriteLine($"{(ok ? "ok " : "FAIL")} {what}"); if (!ok) failures++; } + +// ---- block 21, verbatim ---- +// Ensure dead-letter queue is monitored +var subscription = new AzureServiceBusSubscription( + new SubscriptionName("scheduler-subscription"), + new ChannelName("scheduler-channel"), + new RoutingKey("brighter-scheduler-topic"), + subscriptionConfiguration: new AzureServiceBusSubscriptionConfiguration + { + DeadLetteringOnMessageExpiration = true, // Expired messages go to the DLQ (the default) + MaxDeliveryCount = 5 // Dead-lettered after 5 failed deliveries (the default) + } +); +// ---- end ---- +var plain = new AzureServiceBusSubscription( + new SubscriptionName("scheduler-subscription"), new ChannelName("scheduler-channel"), new RoutingKey("brighter-scheduler-topic")); +Check($"case block 21: DLQ on expiry {subscription.Configuration.DeadLetteringOnMessageExpiration}, max deliveries {subscription.Configuration.MaxDeliveryCount}", + subscription.Configuration.DeadLetteringOnMessageExpiration && subscription.Configuration.MaxDeliveryCount == 5); +Check($"control block 21, no configuration: DLQ on expiry {plain.Configuration.DeadLetteringOnMessageExpiration}, max deliveries {plain.Configuration.MaxDeliveryCount} (\"the default\")", + plain.Configuration.DeadLetteringOnMessageExpiration == subscription.Configuration.DeadLetteringOnMessageExpiration + && plain.Configuration.MaxDeliveryCount == subscription.Configuration.MaxDeliveryCount); + +{ +// ---- block 25's subscription, verbatim but for the `// ...` the page elides ---- +var lockSubscription = new AzureServiceBusSubscription( + // ... + subscriptionConfiguration: new AzureServiceBusSubscriptionConfiguration + { + LockDuration = TimeSpan.FromMinutes(2) // Increase if processing takes longer + } +); +// ---- end ---- +var lockDefault = new AzureServiceBusSubscription(); +Check($"case block 25: LockDuration {lockSubscription.Configuration.LockDuration}", lockSubscription.Configuration.LockDuration == TimeSpan.FromMinutes(2)); +Check($"control block 25, no configuration: LockDuration {lockDefault.Configuration.LockDuration}, which 2 minutes increases", lockDefault.Configuration.LockDuration < lockSubscription.Configuration.LockDuration); +} +return failures == 0 ? 0 : 1; diff --git a/spec/018-compile_residual/probe/s1/azure-config/azure-config.csproj b/spec/018-compile_residual/probe/s1/azure-config/azure-config.csproj new file mode 100644 index 0000000..6240412 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/azure-config/azure-config.csproj @@ -0,0 +1,8 @@ + + + Exenet9.0enable + + + + + diff --git a/spec/018-compile_residual/probe/s1/custom-scheduler/Program.cs b/spec/018-compile_residual/probe/s1/custom-scheduler/Program.cs new file mode 100644 index 0000000..6603785 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/custom-scheduler/Program.cs @@ -0,0 +1,194 @@ +// Task 3.5, CustomScheduler.md. Claims: a message scheduled through CustomMessageScheduler (block 1) +// is produced when the job fires and CustomSchedulerHandler (block 2) sends its payload; a request +// scheduled through CustomRequestScheduler (block 3) reaches its handler when CustomRequestHandler +// (block 4) sends its payload. Case: schedule, fire. Control: schedule, do not fire. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.JsonConverters; +using Paramore.Brighter.Observability; +using Paramore.Brighter.Scheduler.Events; + +var failures = 0; +void Check(string what, bool ok) { Console.WriteLine($"{(ok ? "ok " : "FAIL")} {what}"); if (!ok) failures++; } + +foreach (var fire in new[] { true, false }) +{ + var label = fire ? "case " : "control"; + var bus = new InternalBus(); + var topic = new RoutingKey("greetings"); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddBrighter() + .AddProducers(o => o.ProducerRegistry = new InMemoryProducerRegistryFactory(bus, + [new Publication { Topic = topic, RequestType = typeof(Greeting) }], InstrumentationOptions.None).Create()) + .AutoFromAssemblies([typeof(Greeting).Assembly]); + var provider = services.BuildServiceProvider(); + var processor = provider.GetRequiredService(); + var api = new CustomSchedulerAPI(); + + // Message: blocks 1 and 2 + var message = new Message(new MessageHeader(Id.Random(), topic, MessageType.MT_COMMAND), new MessageBody("hello")); + var messageScheduler = new CustomMessageScheduler(api, TimeProvider.System); + var messageJob = await messageScheduler.ScheduleAsync(message, TimeSpan.FromMinutes(5)); + if (fire) await new CustomSchedulerHandler(processor).ExecuteAsync(api.Jobs[messageJob]); + var produced = bus.Stream(topic).Any(m => m.Body.Value == "hello"); + Check($"{label} message: produced on '{topic}' = {produced}", produced == fire); + + // Request: blocks 3 and 4 + var requestScheduler = new CustomRequestScheduler(api, TimeProvider.System); + var greeting = new Greeting { Name = "Ada" }; + var requestJob = requestScheduler.Schedule(greeting, RequestSchedulerType.Send, TimeSpan.FromMinutes(5)); + if (fire) await new CustomRequestHandler(processor).ExecuteAsync(api.Jobs[requestJob]); + var handled = provider.GetRequiredService().Names.Contains("Ada"); + Check($"{label} request: GreetingHandler saw 'Ada' = {handled}", handled == fire); +} +return failures == 0 ? 0 : 1; + +public class Greeting() : Command(Id.Random()) { public string Name { get; set; } = ""; } +public class Seen { public List Names { get; } = []; } +public class GreetingHandler(Seen seen) : RequestHandler +{ + public override Greeting Handle(Greeting command) { seen.Names.Add(command.Name); return base.Handle(command); } +} + +// The reader's scheduler client, faked: it keeps each job until the probe fires it. +public class CustomSchedulerAPI +{ + public Dictionary Jobs { get; } = []; + public string Schedule(CustomSchedulerObject job) { var id = Guid.NewGuid().ToString(); Jobs[id] = job; return id; } + public Task ScheduleAsync(CustomSchedulerObject job, CancellationToken cancellationToken) => Task.FromResult(Schedule(job)); + public bool Reschedule(string id, DateTimeOffset at) { if (!Jobs.TryGetValue(id, out var j)) return false; j.At = at; return true; } + public void Cancel(string id) => Jobs.Remove(id); +} +public class CustomSchedulerObject { public string Data { get; set; } = string.Empty; public DateTimeOffset At { get; set; } } + +// ---- CustomScheduler.md block 1, verbatim ---- +public class CustomMessageScheduler(CustomSchedulerAPI scheduler, TimeProvider timeProvider) + : IAmAMessageSchedulerAsync, IAmAMessageSchedulerSync +{ + public string Schedule(Message message, DateTimeOffset at) + => scheduler.Schedule(Fire(message, async: false, at)); + + public string Schedule(Message message, TimeSpan delay) + => Schedule(message, timeProvider.GetUtcNow() + delay); + + public bool ReScheduler(string schedulerId, DateTimeOffset at) + => scheduler.Reschedule(schedulerId, at); + + public bool ReScheduler(string schedulerId, TimeSpan delay) + => ReScheduler(schedulerId, timeProvider.GetUtcNow() + delay); + + public void Cancel(string id) => scheduler.Cancel(id); + + public Task ScheduleAsync(Message message, DateTimeOffset at, CancellationToken cancellationToken = default) + => scheduler.ScheduleAsync(Fire(message, async: true, at), cancellationToken); + + public Task ScheduleAsync(Message message, TimeSpan delay, CancellationToken cancellationToken = default) + => ScheduleAsync(message, timeProvider.GetUtcNow() + delay, cancellationToken); + + public Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, at)); + + public Task ReSchedulerAsync(string schedulerId, TimeSpan delay, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, delay)); + + public Task CancelAsync(string id, CancellationToken cancellationToken = default) + { + Cancel(id); + return Task.CompletedTask; + } + + // Async records which call scheduled the message, so the handler fires it the same way + private static CustomSchedulerObject Fire(Message message, bool async, DateTimeOffset at) => new() + { + Data = JsonSerializer.Serialize( + new FireSchedulerMessage { Message = message, Async = async }, + JsonSerialisationOptions.Options), + At = at + }; +} + +// ---- block 2, verbatim ---- +public class CustomSchedulerHandler(IAmACommandProcessor processor) +{ + public async Task ExecuteAsync(CustomSchedulerObject obj) + { + var fire = JsonSerializer.Deserialize(obj.Data, JsonSerialisationOptions.Options)!; + await processor.SendAsync(fire); + } +} + +// ---- block 3, verbatim ---- +public class CustomRequestScheduler(CustomSchedulerAPI scheduler, TimeProvider timeProvider) + : IAmARequestSchedulerAsync, IAmARequestSchedulerSync +{ + public string Schedule(TRequest request, RequestSchedulerType type, DateTimeOffset at) + where TRequest : class, IRequest + => scheduler.Schedule(Fire(request, type, async: false, at)); + + public string Schedule(TRequest request, RequestSchedulerType type, TimeSpan delay) + where TRequest : class, IRequest + => Schedule(request, type, timeProvider.GetUtcNow() + delay); + + public bool ReScheduler(string schedulerId, DateTimeOffset at) + => scheduler.Reschedule(schedulerId, at); + + public bool ReScheduler(string schedulerId, TimeSpan delay) + => ReScheduler(schedulerId, timeProvider.GetUtcNow() + delay); + + public void Cancel(string id) => scheduler.Cancel(id); + + public Task ScheduleAsync(TRequest request, RequestSchedulerType type, DateTimeOffset at, + CancellationToken cancellationToken = default) + where TRequest : class, IRequest + => scheduler.ScheduleAsync(Fire(request, type, async: true, at), cancellationToken); + + public Task ScheduleAsync(TRequest request, RequestSchedulerType type, TimeSpan delay, + CancellationToken cancellationToken = default) + where TRequest : class, IRequest + => ScheduleAsync(request, type, timeProvider.GetUtcNow() + delay, cancellationToken); + + public Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, at)); + + public Task ReSchedulerAsync(string schedulerId, TimeSpan delay, CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, delay)); + + public Task CancelAsync(string id, CancellationToken cancellationToken = default) + { + Cancel(id); + return Task.CompletedTask; + } + + private static CustomSchedulerObject Fire(TRequest request, RequestSchedulerType type, bool async, + DateTimeOffset at) => new() + { + Data = JsonSerializer.Serialize( + new FireSchedulerRequest + { + SchedulerType = type, + RequestType = typeof(TRequest).FullName!, + RequestData = JsonSerializer.Serialize(request, JsonSerialisationOptions.Options), + Async = async + }, + JsonSerialisationOptions.Options), + At = at + }; +} + +// ---- block 4, verbatim ---- +public class CustomRequestHandler(IAmACommandProcessor processor) +{ + public async Task ExecuteAsync(CustomSchedulerObject obj) + { + var fire = JsonSerializer.Deserialize(obj.Data, JsonSerialisationOptions.Options)!; + await processor.SendAsync(fire); + } +} diff --git a/spec/018-compile_residual/probe/s1/custom-scheduler/custom-scheduler.csproj b/spec/018-compile_residual/probe/s1/custom-scheduler/custom-scheduler.csproj new file mode 100644 index 0000000..a2ab6f9 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/custom-scheduler/custom-scheduler.csproj @@ -0,0 +1,9 @@ + + + Exenet9.0enable + + + + + diff --git a/spec/018-compile_residual/probe/s1/defer-requeue/Program.cs b/spec/018-compile_residual/probe/s1/defer-requeue/Program.cs new file mode 100644 index 0000000..0292f84 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/defer-requeue/Program.cs @@ -0,0 +1,101 @@ +// Task 3.5, BrighterSchedulerSupport.md. Claims: +// block 8 a handler that throws DeferMessageAction has its message requeued, with a delay +// block 9 requeueCount: 3 means "Handle at most 3 times: 2 requeues", and requeueDelay is the +// wait between attempts +// Case: block 8's handler, whose service always throws the transient error, under block 9's +// subscription, its requeueDelay shortened from 5 s (DELAY_MS, default 500) so the probe is quick. +// Control: the same pump and subscription with a service that succeeds: handled once. +// Measured: each gap is requeueDelay plus up to one empty-channel poll (500 ms by default) -- +// 500 ms gave 1010/1007 ms, 1500 ms gave 2015/1508 ms -- so the delay is a floor, not the wait. +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.Observability; +using Paramore.Brighter.ServiceActivator; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; + +var failures = 0; +foreach (var failing in new[] { true, false }) +{ + Attempts.Times.Clear(); + var bus = new InternalBus(); + var topic = new RoutingKey("order.process"); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new OrderService(failing)); + services.AddConsumers(options => + { + options.Subscriptions = + [ + // ---- block 9, verbatim but for the delay ---- + new Subscription( + new SubscriptionName("order.processor"), + channelName: new ChannelName("orders"), + routingKey: new RoutingKey("order.process"), + messagePumpType: MessagePumpType.Proactor, + requeueCount: 3, // Handle at most 3 times: 2 requeues + requeueDelay: TimeSpan.FromMilliseconds(Delay.Ms), // the page waits 5 seconds + timeOut: TimeSpan.FromMilliseconds(200) + ) + ]; + options.DefaultChannelFactory = new InMemoryChannelFactory(bus, TimeProvider.System); + }) + .AddProducers(o => o.ProducerRegistry = new InMemoryProducerRegistryFactory(bus, + [new Publication { Topic = topic, RequestType = typeof(ProcessOrderCommand) }], InstrumentationOptions.None).Create()) + .UseScheduler(new InMemorySchedulerFactory()) + .AutoFromAssemblies([typeof(ProcessOrderCommand).Assembly]); + var provider = services.BuildServiceProvider(); + + var dispatcher = provider.GetRequiredService(); + dispatcher.Receive(); + await provider.GetRequiredService().PostAsync(new ProcessOrderCommand { OrderId = Guid.NewGuid() }); + await Task.Delay(TimeSpan.FromMilliseconds(Delay.Ms * 3 + 2500)); + await dispatcher.End(); + + var times = Attempts.Times.OrderBy(t => t).ToArray(); + var gaps = times.Zip(times.Skip(1), (a, b) => (b - a).TotalMilliseconds).ToArray(); + var label = failing ? "case " : "control"; + var ok = failing ? times.Length == 3 && gaps.All(g => g >= Delay.Ms - 50) : times.Length == 1; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {label} handled {times.Length} time(s), gaps {string.Join(", ", gaps.Select(g => $"{g:F0} ms"))}"); + if (!ok) failures++; +} +return failures == 0 ? 0 : 1; + +public class ProcessOrderCommand() : Command(Id.Random()) { public Guid OrderId { get; set; } } +public interface IOrderService { Task ProcessAsync(Guid orderId, CancellationToken cancellationToken); } +public class DatabaseUnavailableException : Exception; +public class OrderService(bool failing) : IOrderService +{ + public Task ProcessAsync(Guid orderId, CancellationToken cancellationToken) + => failing ? throw new DatabaseUnavailableException() : Task.CompletedTask; +} +public static class Attempts { public static readonly ConcurrentBag Times = []; } + +// ---- block 8, verbatim but for the line that records each attempt ---- +public class ProcessOrderHandlerAsync(IOrderService orderService) : RequestHandlerAsync +{ + public override async Task HandleAsync( + ProcessOrderCommand command, + CancellationToken cancellationToken = default) + { + Attempts.Times.Add(DateTime.UtcNow); // probe + try + { + await orderService.ProcessAsync(command.OrderId, cancellationToken); + return await base.HandleAsync(command, cancellationToken); + } + catch (DatabaseUnavailableException) + { + // Requeue with delay to give database time to recover + throw new DeferMessageAction(); + } + } +} +public static class Delay { public static int Ms = int.TryParse(Environment.GetEnvironmentVariable("DELAY_MS"), out var ms) ? ms : 500; } diff --git a/spec/018-compile_residual/probe/s1/defer-requeue/defer-requeue.csproj b/spec/018-compile_residual/probe/s1/defer-requeue/defer-requeue.csproj new file mode 100644 index 0000000..89ee95a --- /dev/null +++ b/spec/018-compile_residual/probe/s1/defer-requeue/defer-requeue.csproj @@ -0,0 +1,8 @@ + + + Exenet9.0enable + + + + + diff --git a/spec/018-compile_residual/probe/s1/hangfire-factory/Program.cs b/spec/018-compile_residual/probe/s1/hangfire-factory/Program.cs new file mode 100644 index 0000000..54f64a6 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/hangfire-factory/Program.cs @@ -0,0 +1,68 @@ +// Task 3.5, HangfireScheduler.md block 1 and every page that registers Brighter's Hangfire +// scheduler the same way. Claim (block 1): with AddHangfire(...) first, then +// .UseScheduler(new HangfireMessageSchedulerFactory()), the application starts and Brighter +// schedules through Hangfire. +// control the page's form: `new HangfireMessageSchedulerFactory()` while configuring services +// case the repair: the factory built in UseScheduler's provider lambda, after resolving +// the Client Hangfire registers, which initialises Hangfire's storage. Then a request is scheduled, and Hangfire's storage holds one +// scheduled job. +using System; +using System.Threading; +using System.Threading.Tasks; +using Hangfire; +using Hangfire.MemoryStorage; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; + +var failures = 0; + +// control +{ + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHangfire(configuration => configuration.UseMemoryStorage()); + string outcome; + try + { + services.AddBrighter().UseScheduler(new HangfireMessageSchedulerFactory()).AutoFromAssemblies(); + outcome = "configured"; + } + catch (Exception e) { outcome = e.GetType().Name; } + var ok = outcome == nameof(InvalidOperationException); + Console.WriteLine($"{(ok ? "ok " : "FAIL")} control `new HangfireMessageSchedulerFactory()` while configuring: {outcome}"); + if (!ok) failures++; +} + +// case +{ + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHangfire(configuration => configuration.UseMemoryStorage()); + services.AddBrighter() + .UseScheduler(provider => + { + // Resolving Hangfire's client first initialises its storage + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; + }) + .AutoFromAssemblies(); + var provider = services.BuildServiceProvider(); + string outcome; long scheduled = -1; + try + { + var processor = provider.GetRequiredService(); + await processor.SendAsync(TimeSpan.FromMinutes(5), new Ping()); + scheduled = provider.GetRequiredService().GetMonitoringApi().ScheduledCount(); + outcome = "scheduled"; + } + catch (Exception e) { outcome = $"{e.GetType().Name}: {e.Message.Split('\n')[0]}"; } + var ok = scheduled == 1; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} case the factory built in UseScheduler's provider lambda: {outcome}, Hangfire scheduled jobs = {scheduled}"); + if (!ok) failures++; +} +return failures == 0 ? 0 : 1; + +public class Ping() : Command(Id.Random()); +public class PingHandler : RequestHandlerAsync; diff --git a/spec/018-compile_residual/probe/s1/hangfire-factory/hangfire-factory.csproj b/spec/018-compile_residual/probe/s1/hangfire-factory/hangfire-factory.csproj new file mode 100644 index 0000000..aff513a --- /dev/null +++ b/spec/018-compile_residual/probe/s1/hangfire-factory/hangfire-factory.csproj @@ -0,0 +1,10 @@ + + + Exenet9.0enable + + + + + + + diff --git a/spec/018-compile_residual/probe/s1/inmemory-block1/Program.cs b/spec/018-compile_residual/probe/s1/inmemory-block1/Program.cs new file mode 100644 index 0000000..0521ea6 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/inmemory-block1/Program.cs @@ -0,0 +1,71 @@ +// Task 3.5, InMemoryScheduler.md block 1. Claims: SendAsync(delay) returns a scheduler id, and the +// command runs after the delay; the block's last comment says "Verify command was handled...". +// AutoFromAssemblies() registers TestCommandHandler, which counts. Case: the block as written, +// then the count after its own Task.Delay(150). Control: the count before the delay, at once. +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Xunit; + +try +{ + await new Block1().Should_Schedule_Command_For_Later_Execution(); +} +catch (Exception e) +{ + Console.WriteLine($"FAIL case block 1: {e.GetType().Name} {e.Message.Split('\n')[0]}"); + return 1; +} +var ok = Block1.HandledAtOnce == 0 && TestCommandHandler.Handled == 1; +Console.WriteLine($"{(Block1.HandledAtOnce == 0 ? "ok " : "FAIL")} control block 1: handled at once = {Block1.HandledAtOnce}"); +Console.WriteLine($"{(TestCommandHandler.Handled == 1 ? "ok " : "FAIL")} case block 1: scheduler id returned, handled after the delay = {TestCommandHandler.Handled}"); +return ok ? 0 : 1; + +public class TestCommand() : Command(Id.Random()); +public class TestCommandHandler : RequestHandlerAsync +{ + public static int Handled; + public override Task HandleAsync(TestCommand command, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref Handled); + return base.HandleAsync(command, cancellationToken); + } +} + +// ---- InMemoryScheduler.md block 1, verbatim but for the class it is a method of, and the one +// probe line that records the control ---- +public class Block1 +{ +public static int HandledAtOnce = -1; + +[Fact] +public async Task Should_Schedule_Command_For_Later_Execution() +{ + // Arrange + var services = new ServiceCollection(); + services.AddBrighter(options => + { + // ... + }) + .UseScheduler(new InMemorySchedulerFactory()) // Perfect for tests + .AutoFromAssemblies(); + + var provider = services.BuildServiceProvider(); + var commandProcessor = provider.GetRequiredService(); + + // Act + var schedulerId = await commandProcessor.SendAsync( + TimeSpan.FromMilliseconds(100), + new TestCommand() + ); + HandledAtOnce = TestCommandHandler.Handled; // probe: the control + + // Assert + Assert.NotNull(schedulerId); + await Task.Delay(150); // Wait for scheduled execution + // Verify command was handled... +} +} diff --git a/spec/018-compile_residual/probe/s1/inmemory-block1/inmemory-block1.csproj b/spec/018-compile_residual/probe/s1/inmemory-block1/inmemory-block1.csproj new file mode 100644 index 0000000..b0b7ae8 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/inmemory-block1/inmemory-block1.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable + + + + + + + diff --git a/spec/018-compile_residual/probe/s1/inmemory-tests/Program.cs b/spec/018-compile_residual/probe/s1/inmemory-tests/Program.cs new file mode 100644 index 0000000..6e58114 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/inmemory-tests/Program.cs @@ -0,0 +1,129 @@ +// Task 3.5, InMemoryScheduler.md. Claims, as the tests assert them: +// block 9 Should_Execute_Scheduled_Command_After_Delay: not handled at once, handled after the delay +// block 9 Should_Cancel_Scheduled_Command: cancelled through IAmARequestSchedulerAsync, never handled +// Each test is its own control for the others: execute and cancel assert opposite outcomes of the +// same schedule, so a fixture that handled everything, or nothing, fails one of them. The probe +// adds a third control: the cancel test with the CancelAsync line removed must see the command. +// Block 1 is ../inmemory-block1: its AutoFromAssemblies() would register this fixture's handler too. +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Xunit; + +var failures = 0; +async Task Run(string name, Func test, bool shouldPass) +{ + try { await test(); Console.WriteLine($"{(shouldPass ? "ok " : "FAIL")} {name}: passed"); if (!shouldPass) failures++; } + catch (Exception e) { Console.WriteLine($"{(shouldPass ? "FAIL" : "ok ")} {name}: {e.GetType().Name} {e.Message.Split('\n')[0]}"); if (shouldPass) failures++; } +} + +using (var t = new SchedulingTests()) await Run("case block 9 Should_Execute_Scheduled_Command_After_Delay", t.Should_Execute_Scheduled_Command_After_Delay, true); +using (var t = new SchedulingTests()) await Run("case block 9 Should_Cancel_Scheduled_Command", t.Should_Cancel_Scheduled_Command, true); +using (var t = new SchedulingTests()) await Run("control block 9 cancel test without CancelAsync (must fail)", t.Should_Cancel_Without_Cancelling, false); +return failures == 0 ? 0 : 1; + +// TestCommand: the page never shows it; the scaffold's stub, made real. +public class TestCommand() : Command(Id.Random()); + +// ---- block 9, verbatim, plus the control method below the line ---- +public class SchedulingTests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly IAmACommandProcessor _commandProcessor; + private readonly TestHandlerRegistry _handlerRegistry; + + public SchedulingTests() + { + var services = new ServiceCollection(); + _handlerRegistry = new TestHandlerRegistry(); + + services.AddSingleton(_handlerRegistry); + + services.AddBrighter(options => + { + options.HandlerLifetime = ServiceLifetime.Scoped; + }) + .UseScheduler(new InMemorySchedulerFactory()) // InMemory for tests + .AsyncHandlers(registry => registry.RegisterAsync()); + + _serviceProvider = services.BuildServiceProvider(); + _commandProcessor = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task Should_Execute_Scheduled_Command_After_Delay() + { + // Arrange + var command = new TestCommand(); + var delay = TimeSpan.FromMilliseconds(100); + + // Act + var schedulerId = await _commandProcessor.SendAsync(delay, command); + + // Assert - command not yet executed + Assert.False(_handlerRegistry.WasHandled(command.Id)); + + // Wait for scheduled execution + await Task.Delay(delay.Add(TimeSpan.FromMilliseconds(50))); + + // Assert - command executed + Assert.True(_handlerRegistry.WasHandled(command.Id)); + } + + [Fact] + public async Task Should_Cancel_Scheduled_Command() + { + // Arrange + var command = new TestCommand(); + var delay = TimeSpan.FromSeconds(10); // Long delay + var scheduler = _serviceProvider.GetRequiredService(); + + // Act - schedule through the scheduler that cancels it + var schedulerId = await scheduler.ScheduleAsync(command, RequestSchedulerType.Send, delay); + await scheduler.CancelAsync(schedulerId); // Cancel immediately + + // Wait to ensure it would have executed + await Task.Delay(TimeSpan.FromSeconds(11)); + + // Assert - command was NOT executed + Assert.False(_handlerRegistry.WasHandled(command.Id)); + } + + // CONTROL, not on the page: the same test with the CancelAsync line removed + public async Task Should_Cancel_Without_Cancelling() + { + var command = new TestCommand(); + var delay = TimeSpan.FromSeconds(10); + var scheduler = _serviceProvider.GetRequiredService(); + var schedulerId = await scheduler.ScheduleAsync(command, RequestSchedulerType.Send, delay); + await Task.Delay(TimeSpan.FromSeconds(11)); + Assert.False(_handlerRegistry.WasHandled(command.Id)); + } + + public void Dispose() + { + _serviceProvider?.Dispose(); + } +} + +public class TestHandlerRegistry +{ + private readonly ConcurrentDictionary _handled = new(); + + public void Record(Id id) => _handled[id] = true; + public bool WasHandled(Id id) => _handled.ContainsKey(id); + public int HandledCount => _handled.Count; +} + +public class TestCommandHandler(TestHandlerRegistry registry) : RequestHandlerAsync +{ + public override Task HandleAsync(TestCommand command, CancellationToken cancellationToken = default) + { + registry.Record(command.Id); + return base.HandleAsync(command, cancellationToken); + } +} diff --git a/spec/018-compile_residual/probe/s1/inmemory-tests/inmemory-tests.csproj b/spec/018-compile_residual/probe/s1/inmemory-tests/inmemory-tests.csproj new file mode 100644 index 0000000..ec415bd --- /dev/null +++ b/spec/018-compile_residual/probe/s1/inmemory-tests/inmemory-tests.csproj @@ -0,0 +1,11 @@ + + + Exenet9.0enable + + + + + + + diff --git a/spec/018-compile_residual/probe/s1/quartz-config/Program.cs b/spec/018-compile_residual/probe/s1/quartz-config/Program.cs new file mode 100644 index 0000000..fc5e32c --- /dev/null +++ b/spec/018-compile_residual/probe/s1/quartz-config/Program.cs @@ -0,0 +1,112 @@ +// Task 3.5, QuartzScheduler.md. Claims: +// block 2 clustering is enabled by store.UseClustering(…) inside UsePersistentStore +// block 10 q.MisfireThreshold sets the misfire threshold +// block 8 GetOrCreateSchedulerId / GetOrCreateRequestSchedulerId decide the scheduler id +// Cases: the property each block sets, read back from QuartzOptions; for block 8, the id a schedule +// returns. Controls: the same configuration without the call (property absent), and the default +// factory (a fresh id, not the message's). +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + +var failures = 0; +void Check(string what, bool ok) { Console.WriteLine($"{(ok ? "ok " : "FAIL")} {what}"); if (!ok) failures++; } +QuartzOptions Options(Action configure) +{ + var services = new ServiceCollection(); + services.AddLogging(); + services.AddQuartz(configure); + return services.BuildServiceProvider().GetRequiredService>().Value; +} +var connectionString = "Server=localhost;Database=Quartz"; + +// block 2's store, as the page writes it +var clustered = Options(q => q.UsePersistentStore(store => +{ + store.UseSqlServer(sqlServer => { sqlServer.ConnectionString = connectionString; sqlServer.TablePrefix = "QRTZ_"; }); + store.UseNewtonsoftJsonSerializer(); // Use JSON serialization for job data + + // Optional: Enable clustering for HA + store.UseClustering(clustering => + { + clustering.CheckinInterval = TimeSpan.FromSeconds(20); + clustering.CheckinMisfireThreshold = TimeSpan.FromSeconds(30); + }); +})); +var unclustered = Options(q => q.UsePersistentStore(store => +{ + store.UseSqlServer(sqlServer => { sqlServer.ConnectionString = connectionString; sqlServer.TablePrefix = "QRTZ_"; }); + store.UseNewtonsoftJsonSerializer(); +})); +string? Get(QuartzOptions o, string key) => o.TryGetValue(key, out var v) ? v : null; +Check($"case block 2: quartz.jobStore.clustered = {Get(clustered, "quartz.jobStore.clustered") ?? "(absent)"}, checkin {Get(clustered, "quartz.jobStore.clusterCheckinInterval") ?? "(absent)"}", + Get(clustered, "quartz.jobStore.clustered") == "true"); +Check($"control block 2 without UseClustering: quartz.jobStore.clustered = {Get(unclustered, "quartz.jobStore.clustered") ?? "(absent)"}", + Get(unclustered, "quartz.jobStore.clustered") != "true"); + +// block 10 +var misfire = Options(q => { q.MisfireThreshold = TimeSpan.FromMinutes(1); }); +var noMisfire = Options(q => { }); +Check($"case block 10: quartz.jobStore.misfireThreshold = {Get(misfire, "quartz.jobStore.misfireThreshold") ?? "(absent)"}", + Get(misfire, "quartz.jobStore.misfireThreshold") == "60000"); +Check($"control block 10 without it: quartz.jobStore.misfireThreshold = {Get(noMisfire, "quartz.jobStore.misfireThreshold") ?? "(absent)"}", + Get(noMisfire, "quartz.jobStore.misfireThreshold") != "60000"); + +// block 8, against Quartz's in-memory store +foreach (var custom in new[] { true, false }) +{ + var services = new ServiceCollection(); + services.AddLogging(); + services.AddQuartz(); + var builder = services.AddBrighter(options => + { + // ... + }); + if (custom) + { + // ---- block 8's UseScheduler, verbatim ---- + builder.UseScheduler(provider => + { + var schedulerFactory = provider.GetRequiredService(); + var scheduler = schedulerFactory.GetScheduler().GetAwaiter().GetResult(); + + return new QuartzSchedulerFactory(scheduler) + { + // Customize message scheduler ID + GetOrCreateSchedulerId = message => message.Id.Value, + + // Customize request scheduler ID + GetOrCreateRequestSchedulerId = request => + { + if (request is MyCommand command) + { + return command.CorrelationId.ToString(); + } + return request.Id.ToString(); + } + }; + }); + // ---- end ---- + } + else + { + builder.UseScheduler(provider => new QuartzSchedulerFactory( + provider.GetRequiredService().GetScheduler().GetAwaiter().GetResult())); + } + var sp = services.BuildServiceProvider(); + var message = new Message(new MessageHeader(Id.Random(), new RoutingKey("t"), MessageType.MT_COMMAND), new MessageBody("x")); + var messageId = await sp.GetRequiredService().ScheduleAsync(message, TimeSpan.FromHours(1)); + var command = new MyCommand { CorrelationId = Id.Random() }; + var requestId = await sp.GetRequiredService().ScheduleAsync(command, RequestSchedulerType.Send, TimeSpan.FromHours(1)); + var label = custom ? "case block 8" : "control default factory"; + Check($"{label}: message id is the message's = {messageId == message.Id.Value}, request id is the correlation id = {requestId == command.CorrelationId.ToString()}", + (messageId == message.Id.Value) == custom && (requestId == command.CorrelationId.ToString()) == custom); +} +return failures == 0 ? 0 : 1; + +public class MyCommand() : Command(Id.Random()); diff --git a/spec/018-compile_residual/probe/s1/quartz-config/quartz-config.csproj b/spec/018-compile_residual/probe/s1/quartz-config/quartz-config.csproj new file mode 100644 index 0000000..0e44085 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/quartz-config/quartz-config.csproj @@ -0,0 +1,10 @@ + + + Exenet9.0enable + + + + + + + diff --git a/spec/018-compile_residual/probe/s1/scheduler-injection/Program.cs b/spec/018-compile_residual/probe/s1/scheduler-injection/Program.cs new file mode 100644 index 0000000..d36a86a --- /dev/null +++ b/spec/018-compile_residual/probe/s1/scheduler-injection/Program.cs @@ -0,0 +1,35 @@ +// Task 3.5. Claim, made by every page that injects IAmAMessageSchedulerAsync or +// IAmARequestSchedulerAsync into a service (QuartzScheduler.md #12, HangfireScheduler.md #14, …): +// the scheduler is in the container to be injected. Brighter 10.8.0 registers it from +// UseScheduler(factory), and from UseScheduler(provider => factory) registers only the factory. +// case UseScheduler(provider => new QuartzSchedulerFactory(…)), QuartzScheduler.md block 1's form +// control UseScheduler(new InMemorySchedulerFactory()), the instance overload +using System; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + +var rows = new (string label, Action use)[] +{ + ("case UseScheduler(provider => new QuartzSchedulerFactory(…))", b => b.UseScheduler(provider => + { + var schedulerFactory = provider.GetRequiredService(); + var scheduler = schedulerFactory.GetScheduler().GetAwaiter().GetResult(); + return new QuartzSchedulerFactory(scheduler); + })), + ("control UseScheduler(new InMemorySchedulerFactory())", b => b.UseScheduler(new InMemorySchedulerFactory())), +}; +foreach (var (label, use) in rows) +{ + var services = new ServiceCollection(); + services.AddLogging(); + services.AddQuartz(); + use(services.AddBrighter().AutoFromAssemblies()); + var provider = services.BuildServiceProvider(); + var message = provider.GetService() != null; + var request = provider.GetService() != null; + var factory = provider.GetService() != null; + Console.WriteLine($"{label}: factory {factory}, IAmAMessageSchedulerAsync {message}, IAmARequestSchedulerAsync {request}"); +} diff --git a/spec/018-compile_residual/probe/s1/scheduler-injection/scheduler-injection.csproj b/spec/018-compile_residual/probe/s1/scheduler-injection/scheduler-injection.csproj new file mode 100644 index 0000000..67fcf8a --- /dev/null +++ b/spec/018-compile_residual/probe/s1/scheduler-injection/scheduler-injection.csproj @@ -0,0 +1,12 @@ + + + Exenet9.0enable + + + + + + + + + diff --git a/spec/018-compile_residual/probe/s1/switching-flag/Program.cs b/spec/018-compile_residual/probe/s1/switching-flag/Program.cs new file mode 100644 index 0000000..b4dfa54 --- /dev/null +++ b/spec/018-compile_residual/probe/s1/switching-flag/Program.cs @@ -0,0 +1,62 @@ +// Task 3.5, SwitchingSchedulers.md block 7. Claim: the application chooses its scheduler at startup +// from a flag, so the change "can be rolled forward and back without a redeployment". Case: +// UseQuartz = true, and Brighter's message and request scheduler factories are Quartz's. Control: +// UseQuartz = false, and they are Hangfire's. Both resolved from the provider Brighter builds. +using System; +using System.Collections.Generic; +using Hangfire; +using Hangfire.MemoryStorage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Hangfire; +using Paramore.Brighter.MessageScheduler.Quartz; +using Quartz; + +var failures = 0; +foreach (var (flag, expected) in new[] { ("true", typeof(QuartzSchedulerFactory)), ("false", typeof(HangfireMessageSchedulerFactory)) }) +{ + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["UseQuartz"] = flag }).Build(); + var services = new ServiceCollection(); + services.AddLogging(); // the host's, which Quartz requires + services.AddQuartz(); // the Quartz the block's lambda resolves ISchedulerFactory from + services.AddHangfire(c => c.UseMemoryStorage()); // and the Hangfire, as block 3 registers it + + // ---- SwitchingSchedulers.md block 7, verbatim ---- + // Run both schedulers temporarily + var brighter = services.AddBrighter(options => + { + // ... + }) + .AutoFromAssemblies(); + + // Choose based on feature flag or configuration + if (configuration.GetValue("UseQuartz")) + { + brighter.UseScheduler(provider => + { + var factory = provider.GetRequiredService(); + return new QuartzSchedulerFactory(factory.GetScheduler().GetAwaiter().GetResult()); + }); + } + else + { + brighter.UseScheduler(provider => + { + // Resolve Hangfire's client first: that initialises its storage (Brighter#4600) + var client = provider.GetRequiredService(); + return new HangfireMessageSchedulerFactory { Client = client }; + }); + } + // ---- end of block 7 ---- + + var provider = services.BuildServiceProvider(); + var message = provider.GetRequiredService().GetType(); + var request = provider.GetRequiredService().GetType(); + var ok = message == expected && request == expected; + Console.WriteLine($"{(ok ? "ok " : "FAIL")} {(flag == "true" ? "case " : "control")} UseQuartz={flag}: message {message.Name}, request {request.Name}"); + if (!ok) failures++; +} +return failures == 0 ? 0 : 1; diff --git a/spec/018-compile_residual/probe/s1/switching-flag/switching-flag.csproj b/spec/018-compile_residual/probe/s1/switching-flag/switching-flag.csproj new file mode 100644 index 0000000..7fff3ae --- /dev/null +++ b/spec/018-compile_residual/probe/s1/switching-flag/switching-flag.csproj @@ -0,0 +1,14 @@ + + + Exenet9.0enable + + + + + + + + + + + diff --git a/spec/018-compile_residual/probe/second.py b/spec/018-compile_residual/probe/second.py new file mode 100644 index 0000000..eae948d --- /dev/null +++ b/spec/018-compile_residual/probe/second.py @@ -0,0 +1,41 @@ +"""Every error of a page's FAILED blocks at the SECOND compile: after `--classify` has supplied the +`using`s it can, so a missing `using` cannot hide a defect behind it. `--explain` shows the first +compile only, which is why phase 3's defect reading needed this. + +`--classify` stages into a temporary directory and deletes it. This keeps it: `tempfile.mkdtemp` is +pointed at and `shutil.rmtree` is made a no-op, for this process only. Nothing in tools/ +is changed. Then the gate's own Roslyn tool explains the kept stage, one pin directory at a time. + +Usage, from the repo root, after the builds in tools/README.md row 9: + python3 spec/018-compile_residual/probe/second.py ... +Prints id code line:col message for every FAILED block on the pages named. +""" +import contextlib, io, os, shutil, subprocess, sys, tempfile +sys.path.insert(0, 'tools') +import blockcheck as bc + +W = os.path.abspath(sys.argv[1]); pages = sys.argv[2:] +if not pages: + sys.exit('usage: second.py ...') +shutil.rmtree(W, ignore_errors=True); os.makedirs(W) +tempfile.mkdtemp = lambda prefix='', **_: W # the stage lands here +bc.shutil.rmtree = lambda *a, **k: None # and stays + +with contextlib.redirect_stdout(io.StringIO()) as rows: + rc = bc.main(['--classify', *pages]) +if rc: + sys.exit(f'--classify exited {rc}') +failed = [l.split('\t') for l in rows.getvalue().splitlines()] +ident = {(b.rel, b.ordinal): b.ident for b in bc.enumerate_blocks()} +wanted = {ident[(p, int(n))] for p, n, *_ in failed} + +for pin, (_, refs) in sorted(bc.PINS.items()): + stage = os.path.join(W, pin) + if not os.path.isdir(stage): + continue + here = sorted(i for i in wanted if os.path.exists(os.path.join(stage, i + '.cs'))) + if not here: + continue + out = subprocess.run(['dotnet', bc.TOOL_DLL, '--explain', stage, refs, *here], + capture_output=True, text=True) + sys.stdout.write(out.stdout) diff --git a/spec/018-compile_residual/tasks.md b/spec/018-compile_residual/tasks.md index 1eac92c..5f23f25 100644 --- a/spec/018-compile_residual/tasks.md +++ b/spec/018-compile_residual/tasks.md @@ -1030,50 +1030,300 @@ at 2.14 and not before. **Every phase changes the site.** ### Phase 3 — S1, Scheduler *(7 tasks, one PR)* -- [ ] **Task 3.1:** Predict phase 3's gate movement +- [x] **Task 3.1:** Predict phase 3's gate movement - Input: § *The groups*, S1; the recurrence greps for every DEFECT in S1's table (obligation 20) - Output: § *Phase 3 as executed*, *Predicted*: BUILT, FAILED, corpus and `pagelint` debt as - bands, the off-group term listed by page and block, and `symbolcheck` **22 → 23 entries, 3 → 8 - silenced** (design E6) + bands, the off-group term listed by page and block, and `symbolcheck` **22 → 24 entries, 3 → 14 + silenced** (design E6, D10) -- [ ] **Task 3.2:** Repair S1's reachable blocks +- [x] **Task 3.2:** Repair S1's reachable blocks - Input: S1's table, the `built-by-using`, `values` and `page-type` rows; 017 § *Scaffold Stub Rules* - Output: each block given its `using`s in the block, or a stub in a scaffold unit. Each leaves BUILT, or listed in § *Blocks that stay FAILED* with its diagnostic -- [ ] **Task 3.3:** Repair S1's hard blocks - - Input: S1's table, the `parse` and `defect` rows; design E2's buckets; 10.7.0 source for each - defect +- [x] **Task 3.3:** Repair S1's hard blocks + - Input: S1's table, the `parse` and `defect` rows; design E2's buckets; 10.8.0 source for each + defect, the pinned release - Output: each block in one of the four states. Literal `...` rewritten as `// ...`. The three JSON fences (`AwsScheduler.md` #13, #14, `AzureScheduler.md` #19) retagged `json`. Splits in § *Splits*, defects in § *Defect ledger* with their recurrence greps. `AwsScheduler.md` #2 and #3, the V4 blocks, are judged against the V4 pin -- [ ] **Task 3.4:** Repair `AddServiceActivator` and watch for it (P0-10) - - Input: design E6 and D5; `tools/symbolwatch.tsv`'s row format - - Output: `AwsScheduler.md` and `AzureScheduler.md` calling `AddConsumers`; one row in - `tools/symbolwatch.tsv`; `` beside the first site - on `FAQ.md` and `V10MigrationGuide.md`. `symbolcheck` → *"23 entries … 8 silenced"*, exit 0; - `symbolcheck --verify-list` → the new row DEAD at both refs. The red run (7 sites, exit 1) is +- [x] **Task 3.4:** Repair `AddServiceActivator` and `UseExternalBus`, and watch for both (P0-10) + - Input: design E6, D5, D10 and D11; `tools/symbolwatch.tsv`'s row format + - Output: `AwsScheduler.md` and `AzureScheduler.md` calling `AddConsumers` and `AddProducers`; two + rows in `tools/symbolwatch.tsv`; `` and + `` beside the first site on `FAQ.md` and + `V10MigrationGuide.md`. `symbolcheck` → *"24 entries … 14 silenced"*, exit 0; + `symbolcheck --verify-list` → the new rows DEAD at both refs. The red run (15 sites, exit 1) is recorded, and `tools/README.md` row 8 updated -- [ ] **Task 3.5:** Run S1's behavioural claims (AC10) +- [x] **Task 3.5:** Run S1's behavioural claims (AC10) - Input: every block 3.2–3.4 changed that asserts behaviour - Output: § *Phase 3 as executed*, a *Claim / Case / Control* table. Each row names its `probe/s1//Program.cs` (obligation 18) -- [ ] **Task 3.6:** Second pass for S1 (obligation 21) +- [x] **Task 3.6:** Second pass for S1 (obligation 21) - Input: the recurrences 3.1 listed; any ruling asked for in 3.2–3.5 - Output: every recurrence repaired, or its ruling recorded; § *Defect ledger* rows' *After* column filled from their greps, run as written -- [ ] **Task 3.7:** Close phase 3 +- [x] **Task 3.7:** Close phase 3 - Input: § 1 obligations 6, 7, 19 - Output: baseline rows for every new BUILT block; `--report` exit 0; `pagelint` 0 errors; the phase's readings (AC9, AC10) over its diff; gates reconciled against 3.1; the PR, with sign-off and head-ref deletion asked by name +### Phase 3 as executed + +**Measured at phase start, `f3f685f`.** `--report` → **992 / 304 / 671 / 17, 0 findings**, exit 0; +`--classify` → 141 parse, 36 built-by-using, 96 defect, 28 same-page, 77 values, 293 page-type, +settled in 3 rounds. 551 reference assemblies on each pin. + +#### Predicted (3.1) + +**Said and measured.** The pins moved to Brighter **10.8.0** (bugfix 0002, `5e0a63b`) after +§ *The groups* was drawn at 10.7.0. + +| Said | Measured | Where the text now stands | +|---|---|---| +| § *The groups*, drawn at 10.7.0: S5 **22 pages / 136** FAILED, 11 with nothing BUILT; totals **634**, 93 defect, 276 page-type, 37 with nothing BUILT | `groups.py` at `f3f685f`: **S1 identical row for row** (8 / 129, 3 with nothing BUILT). S2–S4 unchanged in pages and FAILED. S5 **22 / 134**, 10 with nothing BUILT: `CausationTrackingStores.md` #1 builds, and `V10MigrationGuide.md` lost a block. Totals **632**, 88 defect, 279 page-type, **36** with nothing BUILT. Two methods: the FAILED column, 632; `awk` over `--report` and the tranche list, 628 on 64 pages + the 4 V4 blocks, 632 | the tables and totals are re-drawn | +| Design E2 and D3: four `OnceOnlyAction.Replay` blocks are *forthcoming*, SKIPPED | it shipped in 10.8.0. `TurningOnReplayOnSeen.md`'s 5 defect blocks are now 1 defect + 4 page-type, so D3's first reason has **0** blocks | design E2, § *Page Repair Rules* and § *The targets* say so | +| Task 3.3: *"10.7.0 source for each defect"* | the pins are 10.8.0 | the task says 10.8.0 | +| Design D5, E6 and task 3.4: `symbolcheck` 22 → 23 entries, 3 → 8 silenced | both sites also chain `UseExternalBus`, dead the same way. **D10** adds its row: **24 entries, 14 silenced** | design D10 and the gate row; tasks 3.1 and 3.4 | +| S1's table: **12 defect** blocks | the second compile's diagnostics (below): **5** are API defects, **4** are two snippets in one fence, **1** is a fragment, and **2** are misreads by the classifier | the table stands, being the instrument's; the repairs follow the reading | + +**Reading S1's 12 defect blocks.** `--explain` shows only the first compile, where a missing `using` +hides what comes after it. `probe/second.py` (obligation 18) keeps `--classify`'s stage and explains +it after the `using`s are supplied: + +```bash +python3 spec/018-compile_residual/probe/second.py contents/AwsScheduler.md … contents/SwitchingSchedulers.md +# 129 FAILED blocks classified … 12 defect (6 pages); 524 diagnostic lines +``` + +| Page | # | Second compile | Reading against 10.8.0 | Repair | +|---|---:|---|---|---| +| `AwsScheduler.md` | 3 | `CS1061` `AddServiceActivator`; `CS1503` ×3 on `SqsSubscription` | ledger 1 and 4. The generic `SqsSubscription` begins `getRequestType` (V4) or `subscriptionName, channelName, channelType, routingKey` (V3); the page passes three names positionally | 3.4; 3.3, named arguments | +| `AzureScheduler.md` | 6 | `CS1061` `AddServiceActivator` | ledger 1 and 4 | 3.4 | +| `AzureScheduler.md` | 22 | `CS1739` `deadLetterQueueHandling` | no such parameter; dead-lettering is set on `AzureServiceBusSubscriptionConfiguration` (`DeadLetteringOnMessageExpiration`) | 3.3, ledger 5 | +| `CustomScheduler.md` | 1 | `CS0535` ×9 | `IAmAMessageSchedulerAsync`/`Sync` have five members each; the block shows two, without the `CancellationToken` | 3.3, ledger 6 | +| `CustomScheduler.md` | 3 | `CS0535` ×10 | the same, for `IAmARequestSchedulerAsync`/`Sync` | 3.3, ledger 6 | +| `BrighterSchedulerSupport.md` | 2, 3 | `CS0128` `schedulerId` | two snippets in one fence | 3.3, split | +| `HangfireScheduler.md` | 25 | `CS0128` `scheduledTime` | two snippets | 3.3, split | +| `QuartzScheduler.md` | 23 | `CS0128` `scheduledTime` | two snippets | 3.3, split | +| `AwsScheduler.md` | 22 | `CS0841` `awsConnection` | a fragment: the value is used in the line that declares it | 3.3 | +| `HangfireScheduler.md` | 1 | `CS1501` `GetConnectionString` | **a misread.** The classifier supplied `Microsoft.EntityFrameworkCore`, whose `DatabaseFacade.GetConnectionString()` takes no argument. With `using Microsoft.Extensions.Configuration;` the block **builds** | 3.2, a `using` | +| `HangfireScheduler.md` | 7 | `CS0266` `IsolationLevel` | **a misread.** It supplied `System.Data`; Hangfire.MySql takes `System.Transactions.IsolationLevel`. With that `using`, only `configuration` and `connectionString` remain: *values* | 3.2, a `using` and a stub | + +The two misreads were each compiled with the corrected `using` in scratch, against the main pin. + +**Recurrence greps (obligation 20), run as written at `f3f685f`:** + +| Defect | Grep | Hits | Off-group | +|---|---|---:|---| +| 1 `AddServiceActivator` as current code | `grep -rn 'Services.AddServiceActivator(' contents/` | **2** | none; the 5 V9 discussion lines on `FAQ.md` and `V10MigrationGuide.md` take an opt-out (D5) | +| 4 `UseExternalBus` as current code | `grep -rn '^\.UseExternalBus(' contents/` | **2** | none; the 6 discussion lines take an opt-out (D10) | +| 5 `deadLetterQueueHandling` | `grep -rn 'deadLetterQueueHandling' contents/` | **1** | none | +| 6 a scheduler interface implemented in part | `grep -rnE 'class \w+ : IAmA(Message\|Request)SchedulerAsync' contents/` | **2** | none | +| 7 `SqsSubscription` given its names positionally | `grep -rn -A1 'new SqsSubscription<' contents/ \| grep -E '^\S+-\s+new SubscriptionName\('` | **1** | none. `AWSSQSConfiguration.md`'s four use `name:`, a different defect, which `--classify` already counts on that page (`AWSSQSConfiguration.md` #7, `CS1739`) | + +**The off-group term is two pages and no block.** `FAQ.md` and `V10MigrationGuide.md` gain the two +opt-out comments, outside any fence, so no verdict and no `pagelint` count can move on them. + +**Predicted:** + +| Gate | At `f3f685f` | Predicted at the close | Mechanism | +|---|---|---|---| +| corpus | 992 | **993–1,000** | −3 for the JSON retags; **+4** at least for the four `CS0128` splits; up to +4 more if a parse block holds types and statements | +| BUILT | 304 | **370–425** | S1's 86 reachable blocks (8 built-by-using, 42 values, 36 page-type), less those a stub cannot reach, plus the repaired hard blocks and their splits. Off-group: **0** | +| FAILED | 671 | **544–580** | −129 for S1, plus what stays FAILED and listed: at least the 2 *same-page*, `HangfireScheduler.md` #12 and #20 | +| SKIPPED | 17 | **17–35** | D3's *single option shown alone*: `AwsScheduler.md` #17, #23, #24, `AzureScheduler.md` #15, #16 and kin, each where its type resolves in the pin | +| `pagelint` | 0 errors / 512 | **0 errors / 400–460** | S1's 111 warnings (21 + 20 + 5 + 4 + 18 + 11 + 25 + 7, `--per-page`) fall as blocks gain their `using`s or leave the C# corpus; a declared `// ...` keeps its warning. Off-group: **0** | +| `symbolcheck` | 22 entries, 3 silenced | **24 entries, 14 silenced**, exit 0 | D5 and D10 | +| scaffold rule | 45 units | **46–52 units**, 0 violations, 0 compile errors | a unit per S1 page that needs a stub; `AzureSchedulerContext.cs` exists | +| `linkcheck` · shape · redirects · `--verify` | 168 / 0 · 161, widest 12 · 77 · 161 = 161 | **unmoved** | no link or `SUMMARY.md` change; `spec/` is outside the walk | +| `versioncheck` · `optioncheck` | 0 / 18 · 0 / 59 / 529 | **unmoved** | no tutorial pin and no option table touched | +| `pagelint --plant` | 5 / 5 | **unmoved** | | + +#### `AddServiceActivator` and `UseExternalBus` (3.4, D5, D10, D11) + +**The pages.** `AwsScheduler.md` #3 and `AzureScheduler.md` #6 now call `AddConsumers` and chain +`AddProducers`, each with `using Paramore.Brighter.Extensions.DependencyInjection;` added beside the +ServiceActivator one, since `AddProducers` lives there at 10.8.0. `AwsScheduler.md` #3 also passes +its three names to `SqsSubscription` by name (ledger 7). At the second compile, +`AzureScheduler.md` #6 has **no** diagnostic left, and `AwsScheduler.md` #3 has two, `credentials` +and `RegionEndpoint`: a value and a `using`, both 3.2's. + +**The watchlist.** Two rows, `AddServiceActivator` → `AddConsumers` and `UseExternalBus` → +`AddProducers`. Both opt-outs sit on their own lines at each discussion page's first site: in +`FAQ.md` above the list that names them, and in `V10MigrationGuide.md` under *Breaking Change*, +above the V9 block's skip marker and not between it and its fence. + +| Run | Exit | Output | +|---|---:|---| +| **Red**: the two rows, pages as at `f3f685f` (`git stash push -- contents/`) | **1** | *"15 site(s) across 4 page(s), from 24 watchlist entries"*: `AddServiceActivator` 7, `UseExternalBus` 8 | +| **Green**: pages repaired, opt-outs in | **0** | *"24 entries, 161 pages checked, 14 silenced"*: `FAQ.md` 2 + 2, `V10MigrationGuide.md` 3 + 4, and the 3 from before | + +**`--verify-list` (D11).** Task 3.4 said the new rows would resolve DEAD. They resolved **LIVE**, +exit 1: `AddServiceActivator` in 2 files and `UseExternalBus` in 1, at 10.8.0 and at +`origin/master`, every hit a `///` doc comment. `resolve()` now reads each file `git grep -l` +names, at its ref, through `strip_noncode`. + +| Case | Before D11 | After D11 | +|---|---|---| +| `AddServiceActivator` (comments only) | LIVE, 2 / 2 | **DEAD**, 0 / 0 | +| `UseExternalBus` (comments only) | LIVE, 1 / 1 | **DEAD**, 0 / 0 | +| Control present, `CommandProcessor` | LIVE, 45 / 45 | **LIVE**, 15 / 15 | +| Control absent, `IAmAnIbox` | DEAD, 0 / 0 | **DEAD**, 0 / 0 | +| The 22 rows before this phase | DEAD | **DEAD**, all 22 | +| Every replacement, `AddConsumers` and `AddProducers` included | live | **live** | +| Exit | **1** | **0**, *"All 24 entries still dead at both refs"* | + +**`CommandProcessor` 45 → 15 was read, not trusted.** Each of the 30 files that dropped out was +re-read at 10.8.0, and every line naming `CommandProcessor` as a word starts `//`, `*` or `/*`. +None is code, so the strip took nothing a compiler would see. + +#### The repairs (3.2, 3.3) + +**Every S1 block leaves in one of the four states.** Of the 129 FAILED at `f3f685f`, **122** are BUILT, +**3** are retagged `json` (`AwsScheduler.md` #13, #14; `AzureScheduler.md` #19), **4** stay FAILED and +are listed (§ *Blocks that stay FAILED*), all *same-page*, and **none** is SKIPPED. Four fences were +split (§ *Splits*), so the S1 pages now hold 141 C# blocks and 137 of them build. Eight scaffold +units are new (`AwsScheduler`, `BrighterSchedulerSupport`, `CustomScheduler`, `HangfireScheduler`, +`InMemoryScheduler`, `QuartzScheduler`, `SwitchingSchedulers`) or extended (`AzureScheduler`). + +**No block was SKIPPED under D3.** Each single option shown alone (`AwsScheduler.md` #15, #16, #21, +#22 as they now number; `AzureScheduler.md` #15, #16) was made whole inside the call it belongs to, +which costs a line or two and gives the reader something that runs. + +**What the repairs found that the table did not.** The table's 12 *defect* blocks held 5 API defects +(3.1). The *parse* and *values* rows hid 15 more falsehoods (ledger 8–19), and running the blocks +found the twentieth (below). The pattern behind them is the same one: a block that does not compile +has never been checked for anything else. + +**Statements before types.** `HangfireScheduler.md` #11, #17, #18 and `QuartzScheduler.md` #15, #16 +declared a type and then wrote statements after it, which C# rejects. Each now registers first and +declares after, which is the order a `Program.cs` needs. + +**A rule the repairs kept: services take their dependencies.** Every *values* block that named a +`_repository`, `_logger` or `_commandProcessor` it never declared (`OrderService`, `ReportService`, +`TrialService`, `AnalyticsService` on five pages) now takes them by primary constructor. A field +stub could not have typed `_repository` without inventing a type the page never names (017 +§ *Scaffold Stub Rules*, rule 1). + +#### Behaviour (3.5, AC10) + +**Every run is against the released 10.8.0 packages**, as `probe/s1//` projects, and each +prints `ok` or `FAIL` per row and exits 1 on any `FAIL`. Each block is copied verbatim, the probe's +own lines marked `// probe`. + +| Claim | Case | Control | Probe | Result | +|---|---|---|---|---| +| `CustomScheduler.md` #1, #2: a message scheduled through `CustomMessageScheduler` is produced when its job fires and `CustomSchedulerHandler` sends the payload | schedule, fire | schedule, do not fire | `custom-scheduler` | produced / not produced | +| `CustomScheduler.md` #3, #4: a request scheduled through `CustomRequestScheduler` reaches its handler when `CustomRequestHandler` sends the payload | schedule, fire | schedule, do not fire | `custom-scheduler` | handled / not handled | +| `InMemoryScheduler.md` #9: `Should_Execute_Scheduled_Command_After_Delay` and `Should_Cancel_Scheduled_Command` pass | both tests, as written | the cancel test without its `CancelAsync` line, which must fail | `inmemory-tests` | pass, pass / `Assert.False() Failure` | +| `InMemoryScheduler.md` #1: `SendAsync(delay)` returns an id, and the command runs after the delay | the block, then the count after its `Task.Delay(150)` | the count straight after `SendAsync` | `inmemory-block1` | 1 / 0 | +| `SwitchingSchedulers.md` #7: a flag chooses the scheduler at startup | `UseQuartz=true` → both factories Quartz's | `UseQuartz=false` → both Hangfire's | `switching-flag` | as claimed, both ways | +| `AzureScheduler.md` #21: `DeadLetteringOnMessageExpiration = true` and `MaxDeliveryCount = 5` are the defaults | the block's subscription | a subscription with no configuration: the same values | `azure-config` | true, 5 / true, 5 | +| `AzureScheduler.md` #25: `LockDuration` set on the configuration is the subscription's, and 2 minutes increases it | the block's subscription | no configuration: 1 minute | `azure-config` | 2:00 / 1:00 | +| `QuartzScheduler.md` #2: `store.UseClustering(…)` turns clustering on | the block's store | the same store without it | `quartz-config` | `clustered = true` / absent | +| `QuartzScheduler.md` #10: `q.MisfireThreshold` sets the threshold | the block | no setting | `quartz-config` | `60000` / absent | +| `QuartzScheduler.md` #8: the two hooks decide the scheduler ids | the block's factory | the default factory | `quartz-config` | the message's id and the correlation id / neither | +| `BrighterSchedulerSupport.md` #8, #9: `DeferMessageAction` requeues after a delay; `requeueCount: 3` handles at most 3 times | block 8's handler always deferring, under block 9's subscription | a service that succeeds | `defer-requeue` | 3 handlings / 1 | +| `HangfireScheduler.md` #1 and every page registering Hangfire the same way: the application starts and Brighter schedules through Hangfire | the repaired form (below), then `SendAsync(TimeSpan.FromMinutes(5), …)` | the page's form | `hangfire-factory` | 1 job in Hangfire's storage / `InvalidOperationException` at startup | + +**The last row overturned the page.** `HangfireMessageSchedulerFactory` initialises `Client = new +BackgroundJobClient()` as a property default (`HangfireMessageSchedulerFactory.cs:18` at 10.8.0). +That reads Hangfire's static `JobStorage.Current`, which `services.AddHangfire(…)` leaves unset until +the provider resolves Hangfire's configuration. So `.UseScheduler(new HangfireMessageSchedulerFactory())`, +written while services are configured, throws, and an object initializer setting `Client` cannot +help, because the default runs first: the first attempt at a repair failed for exactly that reason. +The repair builds the factory in `UseScheduler(provider => …)` after resolving +`IBackgroundJobClientV2`, which initialises the storage. It is ledger 20, at **12** sites on **6** +pages, two of them outside S1 (`InMemoryOptions.md`, `SchedulingAMessage.md`). Obligation 21 repairs +those here. The maintainer ruled that the cause be filed upstream: **BrighterCommand/Brighter#4600**, +2026-10-14. + +**Two readings the runs corrected:** + +| Said | Measured | Where the text now stands | +|---|---|---| +| This phase's reading of `ServiceCollectionExtensions.cs` at 10.8.0: `UseScheduler(provider => …)` registers only the factory, so a page injecting `IAmAMessageSchedulerAsync` beside it would fail | `scheduler-injection`: both overloads leave `IAmAMessageSchedulerAsync` and `IAmARequestSchedulerAsync` resolvable; `AddBrighter` registers them from whichever factory is present | no page changed on that reading | +| `BrighterSchedulerSupport.md` #9: *"Wait 5 seconds between attempts"* | `defer-requeue`: each gap is the delay **plus** up to one empty-channel poll, 500 ms by default (500 ms → 1010, 1007 ms; 1500 ms → 2015, 1508 ms) | *"Wait at least 5 seconds between attempts"* | + +**At 10.8.0, `AutoFromAssemblies()` with a scheduled request no longer fails.** `inmemory-block1` +runs `InMemoryScheduler.md` #1 to the handler with no `ArgumentException`. That bears on the +*"At Brighter 10.7.0"* note at `InMemoryScheduler.md:241`, which is bugfix 0002's follow-up 1 and +not this phase's to rule on; it is recorded here for that follow-up. + +**Read, not run.** Each needs a service the probes do not stand up, and each is read against its +source: +- `HangfireScheduler.md` #30, the allow-all filter: Hangfire's `IDashboardAuthorizationFilter` + contract, `true` authorises +- `HangfireScheduler.md` #6, `PostgreSqlStorageOptions.SchemaName`: Hangfire.PostgreSql 1.21.1, a + live PostgreSQL to observe the schema +- `AzureScheduler.md` #25's handler rethrowing, and the message reaching the dead-letter queue after + `MaxDeliveryCount`: Azure Service Bus's own delivery-count rule + +#### Second pass (3.6) + +**Every recurrence is repaired, and no ruling was asked to stop one.** Ledger rows 1 and 4–20 each +carry their *After*, from their grep run as written: **0** for every row but 6 and 16, whose greps +count sites that are still there, now whole. The recurrence greps found only one off-group site +set, ledger 20's (`InMemoryOptions.md`, `SchedulingAMessage.md`), repaired here under obligation 21. +`SchedulingAMessage.md` #8 is the one baselined off-group block whose text changed, and it still +builds. The off-group term is therefore four pages: the two opt-out pages of 3.1, and these two. + +#### Phase 3 closed (3.7) + +**Baselined.** `baseline.tsv` lists every BUILT block on the eight S1 pages, **137** rows, +replacing their **11** earlier ones (§ the file's own header says how). Five keep their original +ref: `AzureScheduler.md` #1, #17, #18, and #19, #20, which were #20, #21 before the JSON retag, +matched by text against `f3f685f`. Every other row is at **`9aafd19`**. `SchedulingAMessage.md` #8 +moves to `9aafd19` too, re-measured after ledger 20's repair. **Two methods agree:** the rows, +`grep -v '^#' baseline.tsv | grep -vc '^$'` → **430**, and `--report`'s BUILT → **430**. + +**Rule 8.** `pagelint` → **0 errors**, so no block this phase admitted carries a handler attribute +of the wrong kind. + +**Readings (obligation 19).** `git diff -U0 f3f685f HEAD -- contents`, hunks against fence ranges: +- **AC9.** The phase added `// ...` at **14** lines in **14** blocks: `AzureScheduler.md` #25, + `BrighterSchedulerSupport.md` #7, `HangfireScheduler.md` #22, `InMemoryScheduler.md` #1, + `QuartzScheduler.md` #8, #15, #19, and `SwitchingSchedulers.md` #1–#7. All 14 are BUILT, so + `--explain` has nothing to report for any of them: no added omission hides an error. + `HangfireScheduler.md` #22 and `QuartzScheduler.md` #19 are a service method's body shown alone; + their `// ...` names the service, and CI's strict `pagelint --changed` asked for it. `QuartzScheduler.md` #15's is the page's own, moved + with the registration it sits in +- **AC10.** § *Behaviour (3.5)*: twelve claims run with a control, three read and named + +**Gates against 3.1.** + +| Gate | Predicted (3.1) | Measured at the close | | +|---|---|---|---| +| corpus | 993–1,000 | **993** | in band: −3 retags, +4 splits; no block held types and statements that a split needed, since the five that did were reordered instead | +| BUILT | 370–425 | **430** | **above the band by 5** (below) | +| FAILED | 544–580 | **546** | in band | +| SKIPPED | 17–35 | **17** | the floor (below) | +| `pagelint` | 0 errors / 400–460 | **0 / 410** | in band | +| `symbolcheck` | 24 entries, 14 silenced | **24, 14**, exit 0; `--verify-list` all 24 DEAD | as predicted, with D11 | +| scaffold rule | 46–52 units | **52**, 0 violations, 0 compile errors | the top of the band: seven new units | +| assemblies | not predicted | 551 → **552** on each pin | D12, ruled at 3.3 after the prediction | +| `linkcheck` · shape · redirects · `--verify` | unmoved | 168 / 0 · 161, widest 12 of 20 · 77, 7858 bytes · 161 = 161 | as predicted | +| `versioncheck` · `optioncheck` | unmoved | 0 of 18 · 0 / 59 / 529 | as predicted | +| `pagelint --plant` | unmoved | 5 / 5 | as predicted | + +**Why BUILT came in above the band, and SKIPPED at its floor: the same mechanism.** 3.1 expected D3 +to skip the single options shown alone, named five of them and *"kin"*, and held BUILT's ceiling +below S1's whole table by that allowance. None was skipped. Each was made whole inside its call +(§ *The repairs*) and built, nine blocks in all (`AwsScheduler.md` #15, #16, #21, #22; +`AzureScheduler.md` #15, #16; `HangfireScheduler.md` #21; `QuartzScheduler.md` #25, #28). The +excess is that allowance, not a new mechanism, and the number is not adopted: the next phase +predicts knowing that a fragment is made whole before it is skipped. + +--- + ### Phase 4 — S2, Commands, Handlers and Pipelines; Get Started *(6 tasks, one PR)* - [ ] **Task 4.1:** Predict phase 4's gate movement @@ -1211,7 +1461,8 @@ at 2.14 and not before. **Every phase changes the site.** ## The groups *Drawn at task 2.14 from `--classify` at `71cb09b` (phase 2's head after 2.13), and not before (AC8). -They replace design § *The section groups*' working figures, which came from the probe.* +Re-drawn at task 3.1 at `f3f685f`, the pins at Brighter 10.8.0; what that moved is in § *Phase 3 as +executed*. They replace design § *The section groups*' working figures, which came from the probe.* **Every table is drawn by one command**, over three files that committed commands write: @@ -1281,10 +1532,10 @@ S2: 10 pages, 107 FAILED, **7 with nothing BUILT**. | Darker | `QueryPatterns.md` | 5 | 1 | | 3 | | | 1 | 0 | | Darker | `QueryPipeline.md` | 25 | 12 | 3 | 1 | | 2 | 7 | 0 | | Understanding Brighter | `CQRSWithBrighterAndDarker.md` | 11 | 3 | 1 | 3 | 2 | | 2 | 2 | -| Understanding Brighter | `HowConfiguringTheCommandProcessorWorks.md` | 9 | 1 | | | | | 8 | 1 | +| Understanding Brighter | `HowConfiguringTheCommandProcessorWorks.md` | 9 | 1 | | 1 | | | 7 | 1 | | Understanding Brighter | `HowServiceActivatorWorks.md` | 17 | 4 | | 3 | | 1 | 9 | 0 | | Understanding Brighter | `ReactorAndProactor.md` | 13 | | | | | | 13 | 0 | -| **S3** | **10 pages** | **137** | **31** | **7** | **15** | **5** | **7** | **72** | **8** | +| **S3** | **10 pages** | **137** | **31** | **7** | **16** | **5** | **7** | **71** | **8** | S3: 10 pages, 137 FAILED, **6 with nothing BUILT**. @@ -1324,10 +1575,10 @@ S4: 17 pages, 125 FAILED, **10 with nothing BUILT**. | Brighter Configuration | `TestDoubleOptions.md` | 12 | 1 | 1 | 6 | | 1 | 3 | 0 | | V10 Migration | `MigratingToNullableReferenceTypes.md` | 5 | | | 3 | | | 2 | 17 | | V10 Migration | `NullableReferenceTypes.md` | 13 | | 5 | 4 | | | 4 | 3 | -| V10 Migration | `V10MigrationGuide.md` | 19 | 3 | 2 | 1 | 3 | 1 | 9 | 2 | +| V10 Migration | `V10MigrationGuide.md` | 18 | 3 | 2 | | 3 | 1 | 9 | 2 | | Outbox and Inbox | `BrighterInboxSupport.md` | 2 | 1 | | | | | 1 | 0 | | Outbox and Inbox | `BrighterOutboxSupport.md` | 2 | | | 2 | | | | 0 | -| Outbox and Inbox | `CausationTrackingStores.md` | 2 | | | 2 | | | | 0 | +| Outbox and Inbox | `CausationTrackingStores.md` | 1 | | | 1 | | | | 1 | | Outbox and Inbox | `DistributedLock.md` (V4) | 1 | | | | | 1 | | 1 | | Outbox and Inbox | `DynamoDbDistributedLock.md` (V4) | 2 | | | | | 2 | | 0 | | Outbox and Inbox | `EFCoreOutbox.md` | 2 | | | | | | 2 | 0 | @@ -1335,30 +1586,31 @@ S4: 17 pages, 125 FAILED, **10 with nothing BUILT**. | Outbox and Inbox | `MongoDBOutbox.md` | 2 | 1 | | 1 | | | | 3 | | Outbox and Inbox | `OutboxArchiver.md` | 4 | 2 | | 1 | | 1 | | 0 | | Outbox and Inbox | `SweeperCircuitBreaking.md` | 3 | 1 | 1 | 1 | | | | 6 | -| Outbox and Inbox | `TurningOnReplayOnSeen.md` | 6 | | | 5 | | 1 | | 0 | +| Outbox and Inbox | `TurningOnReplayOnSeen.md` | 6 | | | 1 | | 1 | 4 | 0 | | Reference | `FAQ.md` | 17 | 4 | | 5 | | 1 | 7 | 2 | | Health Checks and Observability | `ConfiguringOpenTelemetry.md` | 6 | 3 | | 3 | | | | 0 | | Health Checks and Observability | `Logging.md` | 3 | | | 2 | | | 1 | 2 | -| **S5** | **22 pages** | **136** | **30** | **12** | **39** | **3** | **9** | **43** | **40** | +| **S5** | **22 pages** | **134** | **30** | **12** | **33** | **3** | **9** | **47** | **41** | -S5: 22 pages, 136 FAILED, **11 with nothing BUILT**. +S5: 22 pages, 134 FAILED, **10 with nothing BUILT**. -**Totals: 67 pages, 634 FAILED**, of which 140 are parse, 36 built-by-using, 93 defect, -14 same-page, 75 values and 276 page-type. **37** pages have nothing BUILT. +**Totals: 67 pages, 632 FAILED**, of which 140 are parse, 36 built-by-using, 88 defect, +14 same-page, 75 values and 279 page-type. **36** pages have nothing BUILT. **Two methods agree.** -- `groups.py`'s FAILED column gives **634**, the same as `--report`'s FAILED on those 67 pages. -- `awk` over `--report` and the tranche list alone gives **630** FAILED on **64** off-tranche pages. +- `groups.py`'s FAILED column gives **632**, the same as `--report`'s FAILED on those 67 pages. +- `awk` over `--report` and the tranche list alone gives **628** FAILED on **64** off-tranche pages. Adding the **4** V4 blocks (`S3LuggageStore.md` #1, `DistributedLock.md` #2, - `DynamoDbDistributedLock.md` #1, #2) gives 634. -- The other **36** FAILED blocks on tranche pages are not in any group. + `DynamoDbDistributedLock.md` #1, #2) gives 632. +- The other **39** FAILED blocks on tranche pages are not in any group. **Against the design's working figures:** - **S1–S3 are identical** in pages and FAILED: 8 / 129, 10 / 107 and 10 / 137. -- **S4 is 16 / 124 → 17 / 125, and S5 is 20 / 133 → 22 / 136.** The only difference is the three - (V4) tranche pages, which the task puts in S4 and S5, adding 1 and 3 FAILED blocks. -- **Nothing BUILT is 36 → 37**, again only `DynamoDbDistributedLock.md`. That is the design's 36 - off-tranche pages, and the 39 in § *The targets* is still 36 + 3. +- **S4 is 16 / 124 → 17 / 125, and S5 is 20 / 133 → 22 / 134.** The three (V4) tranche pages, + which the task puts in S4 and S5, add 1 and 3 FAILED blocks. S5 also lost 2 at 10.8.0 + (§ *Phase 3 as executed*). +- **Nothing BUILT is 36**: the design's 36 off-tranche pages, less `CausationTrackingStores.md`, + which gained a BUILT block at 10.8.0, plus `DynamoDbDistributedLock.md`. - Phase 2's four new BUILT blocks are all on tranche pages, which is why no off-tranche figure moved. - The design's *Reachable* and *Hard* columns came from the probe. They are replaced by the six classes, and nothing here is carried from them. @@ -1369,6 +1621,10 @@ S5: 22 pages, 136 FAILED, **11 with nothing BUILT**. | Page | # | Diagnostic | Why it stays | Phase | |---|---:|---|---|---:| +| `HangfireScheduler.md` | 12 | `CS0246` `HangfireAuthorizationFilter` | *same-page*: block 11 declares it, and the sentence before block 12 says so | 3 | +| `HangfireScheduler.md` | 20 | `CS0246` `HangfireAuthorizationFilter` | *same-page*, as block 12 | 3 | +| `InMemoryScheduler.md` | 14 | `CS0103` `_handlerRegistry` | *same-page*: a test method of block 9's `SchedulingTests`, which declares the field; the sentence before it says so | 3 | +| `QuartzScheduler.md` | 22 | `CS0246` `BrighterJobListener`, `BrighterTriggerListener` | *same-page*: block 15 declares `BrighterJobListener`; the trigger listener is the reader's, "written the same way", as the sentence before it says | 3 | ## Splits @@ -1376,6 +1632,10 @@ S5: 22 pages, 136 FAILED, **11 with nothing BUILT**. | Page | Old # | New # | Phase | |---|---:|---|---:| +| `BrighterSchedulerSupport.md` | 2 | 2, 3 | 3 | +| `BrighterSchedulerSupport.md` | 3 | 4, 5 | 3 | +| `HangfireScheduler.md` | 25 | 25, 26 | 3 | +| `QuartzScheduler.md` | 23 | 23, 24 | 3 | ## Defect ledger @@ -1384,9 +1644,26 @@ design, and their greps were run there.* | # | Defect | Page(s) | Recurrence grep | Before | After | Found by | Phase | |---:|---|---|---|---:|---:|---|---:| -| 1 | `AddServiceActivator` called as current code; dead at `10.7.0` and `master` (doc comments only) | `AwsScheduler.md`, `AzureScheduler.md` | `grep -rn 'Services.AddServiceActivator(' contents/` | **2** | | re-derivation (design E2); the watchlist missed it | 3 | +| 1 | `AddServiceActivator` called as current code; dead at `10.7.0` and `master` (doc comments only) | `AwsScheduler.md`, `AzureScheduler.md` | `grep -rn 'Services.AddServiceActivator(' contents/` | **2** | **0** | re-derivation (design E2); the watchlist missed it | 3 | | 2 | Shouldly assertions with the package never named and no `using` | `TestDoubleOptions.md` | `grep -c Shouldly contents/TestDoubleOptions.md` | **0** | | the second compile (design E2, *pin-gap*) | 7 | | 3 | Package spelt `Paramore.Brighter.Inbox.DynamoDb.V4`; the project is `…DynamoDB.V4` | `DynamoInbox.md` (a 017 tranche page; repaired under obligation 21) | `grep -rn 'Inbox.DynamoDb.V4' contents/` | **1** | | re-derivation (requirements, the V4 pin) | 7 | +| 4 | `UseExternalBus` called as current code; dead at `10.8.0` and `master` (one doc comment) | `AwsScheduler.md`, `AzureScheduler.md` | `grep -rn '^\.UseExternalBus(' contents/` | **2** | **0** | task 3.1, chained after defect 1 | 3 | +| 5 | `AzureServiceBusSubscription` given `deadLetterQueueHandling:` and `lockTimeout:`, parameters no overload has at 10.8.0; both settings live on `AzureServiceBusSubscriptionConfiguration` | `AzureScheduler.md` | `grep -rnE 'deadLetterQueueHandling\|lockTimeout: 60' contents/` | **2** | **0** | task 3.1, the second compile (`probe/second.py`) | 3 | +| 6 | A scheduler class implementing `IAmA(Message\|Request)Scheduler(Async\|Sync)` with two of each interface's five members, and no `CancellationToken` | `CustomScheduler.md` | `grep -rnE 'class \w+ : IAmA(Message\|Request)SchedulerAsync' contents/` | **2** | **2**, both whole: each class implements all five members of each interface, and both blocks build | task 3.1, the second compile | 3 | +| 7 | `SqsSubscription` given `SubscriptionName`, `ChannelName`, `RoutingKey` positionally, which no overload takes in that order | `AwsScheduler.md` | `grep -rn -A1 'new SqsSubscription<' contents/ \| grep -E '^\S+-\s+new SubscriptionName\('` | **1** | **0** | task 3.1, the second compile, V4 pin | 3 | +| 8 | `UseScheduler(provider => …)` whose lambda returns a `QuartzSchedulerFactory` on one branch and a `HangfireMessageSchedulerFactory` on the other; `UseScheduler` cannot infer one `T` | `SwitchingSchedulers.md` | `git grep -n -A12 'UseScheduler(provider =>' -- contents/ \| grep -c 'return new HangfireMessageSchedulerFactory'` | **1** | **0** | task 3.3, hidden behind *parse* | 3 | +| 9 | `UseScheduler` called on `IServiceCollection`; it extends `IBrighterBuilder` | `InMemoryScheduler.md`, `QuartzScheduler.md` | `grep -rnE '[Ss]ervices\.UseScheduler\(' contents/` | **4** | **0** | task 3.2, hidden behind *values* | 3 | +| 10 | `ServiceBusDefaultAzureCredentialClientProvider`; the class is `ServiceBusDefaultAzureClientProvider` | `AzureScheduler.md` | `grep -rn ServiceBusDefaultAzureCredentialClientProvider contents/` | **1** | **0** | task 3.2 | 3 | +| 11 | Quartz `UseClustering` called on the configurator `q`; at 3.22.4 it is on the persistent store's options | `QuartzScheduler.md` | `grep -rnE 'q\.UseClustering' contents/` | **5** | **0** | task 3.3 | 3 | +| 12 | `store.UseJsonSerializer()`, `[Obsolete]` at Quartz.Serialization.Json 3.22.4 for `UseNewtonsoftJsonSerializer()`; the package was also unpinned (D12) | `QuartzScheduler.md` | `grep -rn UseJsonSerializer contents/` | **7** | **0** | task 3.3 | 3 | +| 13 | `q.UseMisfireHandler(…)` and `q.UseProperties`, neither of which the Quartz configurator has; `MisfireThreshold` is a property of `q`, and `UseProperties` one of the store's | `QuartzScheduler.md` | `grep -rnE 'UseMisfire.?Handler\|q\.UseProperties' contents/` | **2** | **0** | task 3.3 | 3 | +| 14 | `GetOrCreateMessageSchedulerId` set on `QuartzSchedulerFactory`, whose message hook is `GetOrCreateSchedulerId` (as the page's own table says) | `QuartzScheduler.md` | `grep -n 'GetOrCreateMessageSchedulerId =' contents/QuartzScheduler.md` | **1** | **0** | task 3.3 | 3 | +| 15 | Hangfire.PostgreSql's `SchemaName` set on the bootstrapper options; it is on `PostgreSqlStorageOptions` | `HangfireScheduler.md` | `grep -rnE 'options\.SchemaName' contents/` | **1** | **0** | task 3.2 | 3 | +| 16 | `AllowAllDashboardAuthorizationFilter` used as if Hangfire shipped it; no pinned package declares it | `HangfireScheduler.md` | `grep -rn AllowAllDashboardAuthorizationFilter contents/` against `grep -rn 'class AllowAllDashboardAuthorizationFilter' contents/` | **1 / 0** | **2 / 1**: used once, declared once | task 3.2 | 3 | +| 17 | A test fixture passing an object to `.Handlers(…)`, which takes an `Action`, and setting a request's `Id` from a `Guid`; `Id` converts only from `string` | `InMemoryScheduler.md` | `grep -rnF '.Handlers(_' contents/`; `grep -rnF 'new TestCommand { Id = Guid.NewGuid() }' contents/` | **1**; **2** | **0**; **0** | task 3.3 | 3 | +| 18 | `HandleAsync(FireAzureScheduler …)` shown as the reader's handler; Brighter's `AzureSchedulerFiredHandler` handles it | `AzureScheduler.md` | `grep -rnF 'Task HandleAsync' contents/` | **1** | **0** | task 3.3 | 3 | +| 19 | `SchedulerType` and `FireRequestMessage`, neither in Brighter 10.8.0; they are `RequestSchedulerType` and `FireSchedulerRequest` | `CustomScheduler.md` | `grep -rnE '[^t]SchedulerType type\|FireRequestMessage' contents/` | **4** | **0** | task 3.3, with ledger 6 | 3 | +| 20 | `new HangfireMessageSchedulerFactory()` while services are configured; it throws, since its `Client` default reads Hangfire's `JobStorage.Current` before `AddHangfire` has set it (BrighterCommand/Brighter#4600) | `BrighterSchedulerSupport.md`, `HangfireScheduler.md`, `InMemoryOptions.md`, `InMemoryScheduler.md`, `SchedulingAMessage.md`, `SwitchingSchedulers.md` | `grep -rnE 'new HangfireMessageSchedulerFactory(\(\)\|$)' contents/` | **12** | **0** | task 3.5, `probe/s1/hangfire-factory` | 3 | ## Friction ledger diff --git a/tools/README.md b/tools/README.md index e4528d8..3cad54b 100644 --- a/tools/README.md +++ b/tools/README.md @@ -94,17 +94,24 @@ pages were re-admitted under the new scaffold name. Both pins gained `Shouldly`, each, so row 9's assembly count is per pin. `--report` now also counts a scaffold unit that does not compile, and a page whose `v4` cell disagrees with its own imports, as findings. +**Rows 2, 8 and 9 moved at spec 018 phase 3**, the Scheduler pages. Their repairs brought **126** +blocks into the baseline, 304 → **430**, with **52** scaffold units. Three JSON fences were retagged and +four two-snippet fences split, so the corpus is **993**; the V4 pin's 30 blocks are **28**. Both pins +gained `Quartz.Serialization.Json` (design D12), **552** assemblies each. `pagelint` fell 512 → **410**, +on the blocks the repairs gave their `using`s. Row 8's two new entries are design D5 and D10, and +`--verify-list` now reads a file with its comments and strings stripped (D11). + | # | Gate | Command | Expected at `412fd34` | |---:|---|---|---| | 1 | `linkcheck` | `python3 tools/linkcheck.py` | **168 files, 0 broken** — at bugfix 0002, whose record and probe note are files it walks; it read **166** at `94ad0ed` and **165** at `bd95ee0` | -| 2 | `pagelint` | `python3 tools/pagelint.py` | **0 errors, 512 warnings, 162 pages**, with **1 block marked `attr-mismatch-intended`** — at `94ad0ed` plus spec 018 phase 1, which moved no warning; it read **524** at `bd95ee0`, **537** at `5981b12`, **616** at `ca6a0b2`, **658** at `2223fcb`, **706** at `5407298`, **743** at `b941837`, **744** at `3be2a78` and **757** at `412fd34` | +| 2 | `pagelint` | `python3 tools/pagelint.py` | **0 errors, 410 warnings, 162 pages**, with **1 block marked `attr-mismatch-intended`** — at spec 018 phase 3; it read **512** at `94ad0ed` plus spec 018 phase 1, which moved no warning, **524** at `bd95ee0`, **537** at `5981b12`, **616** at `ca6a0b2`, **658** at `2223fcb`, **706** at `5407298`, **743** at `b941837`, **744** at `3be2a78` and **757** at `412fd34` | | 3 | shape | `python3 tools/urlmap.py --check-shape` | **161 pages, 12 sections, widest 12 of 20, deepest 4 of 4** | | 4 | redirects | `python3 tools/urlmap.py --check-redirects` | **77 entries, 7858 bytes** | | 5 | `versioncheck` | `python3 tools/versioncheck.py` | **0 stale pins of 18, across 5 pages** | | 6 | `optioncheck` | `dotnet run --project tools/optioncheck` | **0 mismatches across 59 tables, 529 rows** — at bugfix 0002, pinned to Brighter 10.8.0; it read **519 rows** against 10.7.0 | | 7 | `--verify` | `python3 tools/urlmap.py --verify` | **161 predicted = 161 published** | -| 8 | `symbolcheck` | `python3 tools/symbolcheck.py` | **0 findings — 22 entries, 161 pages, 3 silenced** — at `3be2a78`; it read **5 entries, 1 silenced** at `412fd34` | -| 9 | `blockcheck` | `python3 tools/blockcheck.py --report` | **992 blocks: 304 BUILT, 671 FAILED, 17 SKIPPED, 0 NOT_COMPILABLE — 0 findings, 17 skipped**, against **551 reference assemblies** on each pin, **30 blocks on the v4 pin**, with **45 scaffold units checked, 0 violations, 0 compile errors** and **4 pages on the v4 pin, 0 violations** — at bugfix 0002, against Brighter 10.8.0; it read **993 blocks, 303 BUILT, 673 FAILED** against 548 at spec 018 phase 2, merged with #199, whose three `Telemetry.md` blocks are FAILED, and **990 blocks, 299 BUILT, 674 FAILED** against 545 with 45 units at `bd95ee0`, **295 BUILT** at `5981b12`, **983 blocks, 234 BUILT** against 542 with 35 units at `ca6a0b2`, **189 BUILT** against 538 with 30 units at `2223fcb`, **982 blocks, 129 BUILT** with 22 units at `5407298`, **989 blocks, 101 BUILT, 872 FAILED** at `b941837`, unmoved at `23aa74f` with 14 units, and **985 blocks, 92 BUILT, 12 SKIPPED** at `1e1944d` | +| 8 | `symbolcheck` | `python3 tools/symbolcheck.py` | **0 findings — 24 entries, 161 pages, 14 silenced** — at spec 018 phase 3, which listed `AddServiceActivator` and `UseExternalBus` with opt-outs on `FAQ.md` and `V10MigrationGuide.md`; it read **22 entries, 3 silenced** at `3be2a78` and **5 entries, 1 silenced** at `412fd34` | +| 9 | `blockcheck` | `python3 tools/blockcheck.py --report` | **993 blocks: 430 BUILT, 546 FAILED, 17 SKIPPED, 0 NOT_COMPILABLE — 0 findings, 17 skipped**, against **552 reference assemblies** on each pin, **28 blocks on the v4 pin**, with **52 scaffold units checked, 0 violations, 0 compile errors** and **4 pages on the v4 pin, 0 violations** — at spec 018 phase 3, against Brighter 10.8.0; it read **992 blocks, 304 BUILT, 671 FAILED** against 551 with 45 units at bugfix 0002, **993 blocks, 303 BUILT, 673 FAILED** against 548 at spec 018 phase 2, merged with #199, whose three `Telemetry.md` blocks are FAILED, and **990 blocks, 299 BUILT, 674 FAILED** against 545 with 45 units at `bd95ee0`, **295 BUILT** at `5981b12`, **983 blocks, 234 BUILT** against 542 with 35 units at `ca6a0b2`, **189 BUILT** against 538 with 30 units at `2223fcb`, **982 blocks, 129 BUILT** with 22 units at `5407298`, **989 blocks, 101 BUILT, 872 FAILED** at `b941837`, unmoved at `23aa74f` with 14 units, and **985 blocks, 92 BUILT, 12 SKIPPED** at `1e1944d` | **Four of the nine are not in the `check` job of `.github/workflows/docs.yml`, and each absence is a decision rather than an oversight:** @@ -298,7 +305,9 @@ that file is a diff someone reads, not a command someone reruns until the build - **`urlmap --verify`** — the predicted URLs against the live sitemap. - **`symbolcheck`** — no page names a symbol on `tools/symbolwatch.tsv`: a name dead in the product at both the pinned release and its `master`. Opt-outs are **per symbol**, written - ``, and always print a count. + ``, and always print a count. `--verify-list` reads each + file `git grep` names with comments and strings stripped, so a name a release mentions only in a + `///` resolves **DEAD** (spec 018 D11). - **`blockcheck`** — every C# block under `contents/` (and the root `README.md`), enumerated through `pagelint`'s parser rather than a grep, **compiled one block per compilation** against diff --git a/tools/blockcheck/baseline.tsv b/tools/blockcheck/baseline.tsv index 89ba8da..86f0990 100644 --- a/tools/blockcheck/baseline.tsv +++ b/tools/blockcheck/baseline.tsv @@ -41,12 +41,6 @@ contents/AWSSQSMigrateToV10.md 1 - 280d1b7 contents/AWSSQSMigrateToV10.md 7 - 280d1b7 contents/AnalyzerSupport.md 1 - ec38400 contents/AsyncAPISupport.md 5 - 280d1b7 -contents/AwsScheduler.md 19 v4 937db00 -contents/AzureScheduler.md 1 AzureSchedulerContext.cs 280d1b7 -contents/AzureScheduler.md 17 AzureSchedulerContext.cs 280d1b7 -contents/AzureScheduler.md 18 AzureSchedulerContext.cs 9c57ae2 -contents/AzureScheduler.md 20 AzureSchedulerContext.cs 280d1b7 -contents/AzureScheduler.md 21 AzureSchedulerContext.cs 280d1b7 contents/BoxProvisioning.md 1 - 280d1b7 contents/BoxProvisioningConfiguration.md 1 BoxProvisioningConfigurationContext.cs 24e6e53 contents/BoxProvisioningConfiguration.md 2 BoxProvisioningConfigurationContext.cs 24e6e53 @@ -94,15 +88,12 @@ contents/HandlingPoisonMessages.md 3 HandlingPoisonMessagesContext.cs 71d0c60 contents/HandlingPoisonMessages.md 4 HandlingPoisonMessagesContext.cs 280d1b7 contents/HandlingPoisonMessages.md 5 HandlingPoisonMessagesContext.cs 280d1b7 contents/HandlingPoisonMessages.md 6 HandlingPoisonMessagesContext.cs 280d1b7 -contents/HangfireScheduler.md 24 - 280d1b7 -contents/HangfireScheduler.md 28 - 280d1b7 contents/HealthChecks.md 1 HealthChecksContext.cs 51b5fef contents/HowConfiguringTheCommandProcessorWorks.md 10 HowConfiguringTheCommandProcessorWorksContext.cs 280d1b7 contents/ImplementAQueryHandler.md 1 - 280d1b7 contents/ImplementAQueryHandler.md 2 - 280d1b7 contents/ImplementingAHandler.md 1 - 0363fae contents/ImplementingAsyncHandler.md 1 - 0363fae -contents/InMemoryScheduler.md 4 - 8090477 contents/InMemoryTransport.md 1 InMemoryTransportContext.cs 7edaada contents/InMemoryTransport.md 2 InMemoryTransportContext.cs 7edaada contents/InMemoryTransport.md 3 InMemoryTransportContext.cs 7edaada @@ -197,7 +188,7 @@ contents/SchedulingAMessage.md 4 SchedulingAMessageContext.cs 3509d3e contents/SchedulingAMessage.md 5 SchedulingAMessageContext.cs 3509d3e contents/SchedulingAMessage.md 6 SchedulingAMessageContext.cs 3509d3e contents/SchedulingAMessage.md 7 SchedulingAMessageContext.cs 3509d3e -contents/SchedulingAMessage.md 8 SchedulingAMessageContext.cs 3509d3e +contents/SchedulingAMessage.md 8 SchedulingAMessageContext.cs 9aafd19 contents/SchedulingAMessage.md 9 SchedulingAMessageContext.cs 3509d3e contents/SchedulingAMessage.md 10 SchedulingAMessageContext.cs 3509d3e contents/SpannerInbox.md 1 - 280d1b7 @@ -323,9 +314,7 @@ contents/Telemetry.md 3 TelemetryContext.cs 2d938c8 contents/Telemetry.md 5 TelemetryContext.cs 2d938c8 contents/PostgreSQLMessageBroker.md 11 PostgreSQLMessageBrokerContext.cs 2d938c8 contents/Telemetry.md 4 TelemetryContext.cs 2d938c8 -contents/InMemoryScheduler.md 5 - b4c3ae0 contents/CQRSWithBrighterAndDarker.md 7 - b4c3ae0 -contents/BrighterSchedulerSupport.md 1 - b4c3ae0 contents/DefaultMessageMappers.md 4 - b4c3ae0 contents/DefaultMessageMappers.md 5 - b4c3ae0 contents/V10MigrationGuide.md 9 - f4cfd88 @@ -336,3 +325,148 @@ contents/MongoDBOutbox.md 1 - 7c6c0a1 contents/MongoDBOutbox.md 2 - 7c6c0a1 contents/MongoDBOutbox.md 3 - 7c6c0a1 contents/CausationTrackingStores.md 1 - 5e0a63b +# +# Spec 018 phase 3 repaired S1, the Scheduler pages. Every BUILT block on its eight pages is +# listed below, replacing their earlier rows: the phase split four fences and retagged three +# JSON blocks, so ordinals moved, and six blocks gained a scaffold unit. A block whose text and +# scaffold did not change keeps its original ref at its present ordinal (AzureScheduler.md #1, +# #17, #18, and #19, #20, which were #20, #21); every other block is at `9aafd19`, the commit +# holding the phase's page and unit work. SchedulingAMessage.md #8 is re-measured there too: its +# Hangfire registration was repaired in this phase (ledger 20). +contents/AwsScheduler.md 1 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 2 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 3 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 4 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 5 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 6 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 7 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 8 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 9 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 10 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 11 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 12 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 13 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 14 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 15 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 16 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 17 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 18 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 19 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 20 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 21 AwsSchedulerContext.cs+v4 9aafd19 +contents/AwsScheduler.md 22 AwsSchedulerContext.cs+v4 9aafd19 +contents/AzureScheduler.md 1 AzureSchedulerContext.cs 280d1b7 +contents/AzureScheduler.md 2 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 3 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 4 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 5 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 6 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 7 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 8 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 9 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 10 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 11 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 12 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 13 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 14 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 15 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 16 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 17 AzureSchedulerContext.cs 280d1b7 +contents/AzureScheduler.md 18 AzureSchedulerContext.cs 9c57ae2 +contents/AzureScheduler.md 19 AzureSchedulerContext.cs 280d1b7 +contents/AzureScheduler.md 20 AzureSchedulerContext.cs 280d1b7 +contents/AzureScheduler.md 21 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 22 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 23 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 24 AzureSchedulerContext.cs 9aafd19 +contents/AzureScheduler.md 25 AzureSchedulerContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 1 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 2 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 3 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 4 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 5 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 6 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 7 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 8 BrighterSchedulerSupportContext.cs 9aafd19 +contents/BrighterSchedulerSupport.md 9 BrighterSchedulerSupportContext.cs 9aafd19 +contents/CustomScheduler.md 1 CustomSchedulerContext.cs 9aafd19 +contents/CustomScheduler.md 2 CustomSchedulerContext.cs 9aafd19 +contents/CustomScheduler.md 3 CustomSchedulerContext.cs 9aafd19 +contents/CustomScheduler.md 4 CustomSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 1 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 2 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 3 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 4 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 5 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 6 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 7 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 8 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 9 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 10 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 11 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 13 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 14 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 15 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 16 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 17 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 18 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 19 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 21 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 22 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 23 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 24 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 25 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 26 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 27 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 28 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 29 HangfireSchedulerContext.cs 9aafd19 +contents/HangfireScheduler.md 30 HangfireSchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 1 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 2 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 3 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 4 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 5 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 6 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 7 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 8 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 9 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 10 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 11 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 12 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 13 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 15 InMemorySchedulerContext.cs 9aafd19 +contents/InMemoryScheduler.md 16 InMemorySchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 1 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 2 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 3 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 4 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 5 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 6 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 7 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 8 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 9 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 10 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 11 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 12 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 13 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 14 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 15 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 16 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 17 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 18 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 19 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 20 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 21 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 23 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 24 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 25 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 26 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 27 QuartzSchedulerContext.cs 9aafd19 +contents/QuartzScheduler.md 28 QuartzSchedulerContext.cs 9aafd19 +contents/SwitchingSchedulers.md 1 SwitchingSchedulersContext.cs 9aafd19 +contents/SwitchingSchedulers.md 2 SwitchingSchedulersContext.cs 9aafd19 +contents/SwitchingSchedulers.md 3 SwitchingSchedulersContext.cs 9aafd19 +contents/SwitchingSchedulers.md 4 SwitchingSchedulersContext.cs 9aafd19 +contents/SwitchingSchedulers.md 5 SwitchingSchedulersContext.cs 9aafd19 +contents/SwitchingSchedulers.md 6 SwitchingSchedulersContext.cs 9aafd19 +contents/SwitchingSchedulers.md 7 SwitchingSchedulersContext.cs 9aafd19 diff --git a/tools/blockcheck/refs-v4/refs-v4.csproj b/tools/blockcheck/refs-v4/refs-v4.csproj index 90213f0..a132492 100644 --- a/tools/blockcheck/refs-v4/refs-v4.csproj +++ b/tools/blockcheck/refs-v4/refs-v4.csproj @@ -222,6 +222,9 @@ + + diff --git a/tools/blockcheck/refs/refs.csproj b/tools/blockcheck/refs/refs.csproj index 349e912..a73294a 100644 --- a/tools/blockcheck/refs/refs.csproj +++ b/tools/blockcheck/refs/refs.csproj @@ -201,6 +201,9 @@ + + diff --git a/tools/blockcheck/scaffold/pages.tsv b/tools/blockcheck/scaffold/pages.tsv index cbc5ddc..c2f932c 100644 --- a/tools/blockcheck/scaffold/pages.tsv +++ b/tools/blockcheck/scaffold/pages.tsv @@ -110,6 +110,12 @@ contents/PostgreSQLBrokerTradeOffs.md PostgreSQLMessageBrokerContext - - contents/CloudEventsReference.md CloudEventsReferenceContext - - contents/Telemetry.md TelemetryContext - - contents/S3LuggageStore.md S3LuggageStoreContext - v4 -contents/AwsScheduler.md - - v4 +contents/AwsScheduler.md AwsSchedulerContext - v4 contents/DistributedLock.md - - v4 contents/DynamoDbDistributedLock.md - - v4 +contents/CustomScheduler.md CustomSchedulerContext - - +contents/BrighterSchedulerSupport.md BrighterSchedulerSupportContext - - +contents/SwitchingSchedulers.md SwitchingSchedulersContext - - +contents/InMemoryScheduler.md InMemorySchedulerContext - - +contents/HangfireScheduler.md HangfireSchedulerContext - - +contents/QuartzScheduler.md QuartzSchedulerContext - - diff --git a/tools/blockcheck/scaffold/units/AwsSchedulerContext.cs b/tools/blockcheck/scaffold/units/AwsSchedulerContext.cs new file mode 100644 index 0000000..ac4a3fd --- /dev/null +++ b/tools/blockcheck/scaffold/units/AwsSchedulerContext.cs @@ -0,0 +1,87 @@ +// Types and values AwsScheduler.md names in its blocks and never declares. +// +// The page is on the V4 pin, so the connection is the .V4 gateway's. The services schedule work +// from a domain the page never shows. Each stub carries only the members a block names. +// +// blockcheck: using static AwsSchedulerContext; + +using System; +using System.Threading.Tasks; +using Amazon.Runtime; +using Microsoft.Extensions.Hosting; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS.V4; + +public static class AwsSchedulerContext +{ + // blocks 1, 4–6, 11–16, 18, 21, 22: `new AwsSchedulerFactory(awsConnection, …)`, built in block 2 + public static AWSMessagingGatewayConnection awsConnection => null!; + // blocks 2, 3, 19, 20: `new AWSMessagingGatewayConnection(credentials, …)`, "From AWS credentials provider" + public static AWSCredentials credentials => null!; + // block 19: `environment.IsDevelopment()` + public static IHostEnvironment environment => null!; + // block 22: `await _scheduler.CancelAsync(oldSchedulerId)` + public static IAmAMessageSchedulerAsync _scheduler => null!; + public static string oldSchedulerId => null!; +} + +// blocks 7, 9: `order.Id`, `order.Total`, `order.PaymentSchedulerId`, `order.Status` +public class Order +{ + public Guid Id { get; set; } + public decimal Total { get; set; } + public string? PaymentSchedulerId { get; set; } + public OrderStatus Status { get; set; } +} + +// block 9: `OrderStatus.Cancelled` +public enum OrderStatus { Cancelled } + +// blocks 7, 9: `repository.SaveAsync`, `GetAsync`, `UpdateAsync` +public interface IOrderRepository +{ + Task SaveAsync(Order order); + Task GetAsync(Guid orderId); + Task UpdateAsync(Order order); +} + +// block 6: `request is ProcessOrderCommand cmd`, `cmd.OrderId` +public class ProcessOrderCommand() : Command(Id.Random()) +{ + public Guid OrderId { get; set; } +} + +// blocks 7, 15: `new ProcessPaymentCommand { OrderId = …, Amount = … }`, `cmd.TransactionId` +public class ProcessPaymentCommand() : Command(Id.Random()) +{ + public Guid OrderId { get; set; } + public decimal Amount { get; set; } + public string TransactionId { get; set; } = string.Empty; +} + +// block 8: `new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date }` +public class GenerateDailyReportCommand() : Command(Id.Random()) +{ + public DateTime Date { get; set; } +} + +// block 10: `user.Id`, `user.TrialStartDate`, `user.TrialEndDate` +public class User +{ + public Guid Id { get; set; } + public DateTime TrialStartDate { get; set; } + public DateTime TrialEndDate { get; set; } +} + +// block 10: `repository.SaveAsync(user)` +public interface IUserRepository +{ + Task SaveAsync(User user); +} + +// block 10: `new TrialExpiringEvent { UserId = user.Id, DaysRemaining = 7 }` +public class TrialExpiringEvent() : Event(Id.Random()) +{ + public Guid UserId { get; set; } + public int DaysRemaining { get; set; } +} diff --git a/tools/blockcheck/scaffold/units/AzureSchedulerContext.cs b/tools/blockcheck/scaffold/units/AzureSchedulerContext.cs index e21471b..e16157c 100644 --- a/tools/blockcheck/scaffold/units/AzureSchedulerContext.cs +++ b/tools/blockcheck/scaffold/units/AzureSchedulerContext.cs @@ -1,4 +1,4 @@ -// Values AzureScheduler.md names in its blocks and never declares. +// Types and values AzureScheduler.md names in its blocks and never declares. // // Identifiers only: every member is typed from a pinned package or the BCL, returns a default, // and does nothing. A block that calls a member of one of these is checked @@ -7,13 +7,58 @@ // blockcheck: using static AzureSchedulerContext; using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; public static class AzureSchedulerContext { + // blocks 17, 24: `_commandProcessor.SendAsync(…)`, a field of the caller's public static IAmACommandProcessor _commandProcessor => null!; + // blocks 17, 18, 24: `_scheduler.CancelAsync(…)` public static IAmAMessageSchedulerAsync _scheduler => null!; + // block 17: `SendAsync(newDelay, command)` public static IRequest command => null!; + // blocks 17, 18: `existingSchedulerId` public static string existingSchedulerId => null!; + // blocks 17, 18: `newDelay` public static TimeSpan newDelay => default; + // blocks 7, 8, 23: `new AzureServiceBusSchedulerFactory(clientProvider, …)`, built in block 1 + public static IServiceBusClientProvider clientProvider => null!; + // block 22: `environment.IsDevelopment()` + public static IHostEnvironment environment => null!; +} + +// blocks 9, 11, 12: `order.Id`, `order.Total`, `order.PaymentSchedulerId`, `order.Status` +public class Order +{ + public Guid Id { get; set; } + public decimal Total { get; set; } + public string? PaymentSchedulerId { get; set; } + public OrderStatus Status { get; set; } +} + +// block 11: `OrderStatus.Cancelled` +public enum OrderStatus { Cancelled } + +// blocks 9, 11, 12: `repository.SaveAsync`, `GetAsync`, `UpdateAsync` +public interface IOrderRepository +{ + Task SaveAsync(Order order); + Task GetAsync(Guid orderId); + Task UpdateAsync(Order order); +} + +// blocks 9, 12, 25: `new ProcessPaymentCommand { OrderId = order.Id, Amount = order.Total }` +public class ProcessPaymentCommand() : Command(Id.Random()) +{ + public Guid OrderId { get; set; } + public decimal Amount { get; set; } +} + +// block 10: `new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date }` +public class GenerateDailyReportCommand() : Command(Id.Random()) +{ + public DateTime Date { get; set; } } diff --git a/tools/blockcheck/scaffold/units/BrighterSchedulerSupportContext.cs b/tools/blockcheck/scaffold/units/BrighterSchedulerSupportContext.cs new file mode 100644 index 0000000..0ec8eb0 --- /dev/null +++ b/tools/blockcheck/scaffold/units/BrighterSchedulerSupportContext.cs @@ -0,0 +1,69 @@ +// Types and values BrighterSchedulerSupport.md names in its blocks and never declares. +// +// The examples schedule commands from a domain the page never shows. Each stub carries only the +// members a block names. +// +// blockcheck: using static BrighterSchedulerSupportContext; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; + +public static class BrighterSchedulerSupportContext +{ + // blocks 2–6: `commandProcessor.SendAsync(…)`, `commandProcessor.Send(…)` + public static IAmACommandProcessor commandProcessor => null!; + // block 2: `OrderId = orderId` + public static Guid orderId => default; + // block 3: `UserId = userId` + public static Guid userId => default; + // block 4: `TransactionId = txId` + public static string txId => null!; + // block 6: `OperationId = opId` + public static Guid opId => default; + // block 6: `await scheduler.CancelAsync(schedulerId)` + public static IAmARequestSchedulerAsync scheduler => null!; + // block 7: `services.AddBrighter(…)` + public static IServiceCollection services => null!; +} + +// blocks 2, 8, 9: `new ProcessOrderCommand { OrderId = orderId }`, `command.OrderId` +public class ProcessOrderCommand() : Command(Id.Random()) +{ + public Guid OrderId { get; set; } +} + +// block 3: `new SendReminderCommand { UserId = userId }` +public class SendReminderCommand() : Command(Id.Random()) +{ + public Guid UserId { get; set; } +} + +// block 4: `new ProcessPaymentCommand { TransactionId = txId }` +public class ProcessPaymentCommand() : Command(Id.Random()) +{ + public string TransactionId { get; set; } = string.Empty; +} + +// block 5: `new RetryOperationCommand { AttemptNumber = 2 }` +public class RetryOperationCommand() : Command(Id.Random()) +{ + public int AttemptNumber { get; set; } +} + +// block 6: `new CancelableOperationCommand { OperationId = opId }` +public class CancelableOperationCommand() : Command(Id.Random()) +{ + public Guid OperationId { get; set; } +} + +// block 8: `orderService.ProcessAsync(command.OrderId, cancellationToken)` +public interface IOrderService +{ + Task ProcessAsync(Guid orderId, CancellationToken cancellationToken); +} + +// block 8: `catch (DatabaseUnavailableException)` +public class DatabaseUnavailableException : Exception; diff --git a/tools/blockcheck/scaffold/units/CustomSchedulerContext.cs b/tools/blockcheck/scaffold/units/CustomSchedulerContext.cs new file mode 100644 index 0000000..79af052 --- /dev/null +++ b/tools/blockcheck/scaffold/units/CustomSchedulerContext.cs @@ -0,0 +1,25 @@ +// Types CustomScheduler.md names in its blocks and never declares. +// +// `CustomSchedulerAPI` and `CustomSchedulerObject` stand for the reader's own scheduler client and +// job type; the page says so (`CustomScheduler.md`, the paragraph after block 1, and the closing +// note). Each stub carries only the members a block calls. + +using System; +using System.Threading; +using System.Threading.Tasks; + +// blocks 1, 3: `scheduler.Schedule`, `ScheduleAsync`, `Reschedule`, `Cancel` +public class CustomSchedulerAPI +{ + public string Schedule(CustomSchedulerObject job) => null!; + public Task ScheduleAsync(CustomSchedulerObject job, CancellationToken cancellationToken) => null!; + public bool Reschedule(string id, DateTimeOffset at) => default; + public void Cancel(string id) { } +} + +// blocks 1–4: `new CustomSchedulerObject { Data = …, At = at }`, `obj.Data` +public class CustomSchedulerObject +{ + public string Data { get; set; } = string.Empty; + public DateTimeOffset At { get; set; } +} diff --git a/tools/blockcheck/scaffold/units/HangfireSchedulerContext.cs b/tools/blockcheck/scaffold/units/HangfireSchedulerContext.cs new file mode 100644 index 0000000..bb580da --- /dev/null +++ b/tools/blockcheck/scaffold/units/HangfireSchedulerContext.cs @@ -0,0 +1,70 @@ +// Types and values HangfireScheduler.md names in its blocks and never declares. +// +// Block 1 is the whole of an application's startup; the blocks after it change one part of it, +// and name the `builder`, `app` and Hangfire `configuration` that startup has in scope. The +// services schedule work from a domain the page never shows. +// +// blockcheck: using static HangfireSchedulerContext; + +using System; +using System.Threading.Tasks; +using Hangfire; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +public static class HangfireSchedulerContext +{ + // blocks 2–4, 10, 16, 18, 23, 27, 28: `builder.Services…`, as block 1 declares it + public static WebApplicationBuilder builder => null!; + // blocks 11, 20, 30: `app.UseHangfireDashboard(…)`, as block 1 declares it + public static WebApplication app => null!; + // blocks 5–9, 19: the `IGlobalConfiguration` that `AddHangfire(configuration => …)` passes + public static IGlobalConfiguration configuration => null!; + // blocks 5–8, 19: the storage's connection string + public static string connectionString => null!; + // block 16: `UseSqlServerStorage(sharedConnectionString)` + public static string sharedConnectionString => null!; + // block 17: `loggerFactory.CreateLogger()` + public static ILoggerFactory loggerFactory => null!; + // block 21: `brighter.UseScheduler(…)`, the builder `AddBrighter` returns + public static IBrighterBuilder brighter => null!; + // block 22: a service's fields and arguments + public static IAmACommandProcessor _commandProcessor => null!; + public static IOrderRepository _repository => null!; + public static TimeSpan delay => default; + public static IRequest command => null!; + public static Order order => null!; +} + +// blocks 13, 14, 22: `order.Id`, `order.ProcessSchedulerId`, `order.Status` +public class Order +{ + public Guid Id { get; set; } + public string? ProcessSchedulerId { get; set; } + public OrderStatus Status { get; set; } +} + +// block 14: `OrderStatus.Cancelled` +public enum OrderStatus { Cancelled } + +// blocks 13, 14, 22: `repository.SaveAsync`, `GetAsync`, `UpdateAsync` +public interface IOrderRepository +{ + Task SaveAsync(Order order); + Task GetAsync(Guid orderId); + Task UpdateAsync(Order order); +} + +// block 13: `new ProcessOrderCommand { OrderId = order.Id }` +public class ProcessOrderCommand() : Command(Id.Random()) +{ + public Guid OrderId { get; set; } +} + +// block 15: `new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date }` +public class GenerateDailyReportCommand() : Command(Id.Random()) +{ + public DateTime Date { get; set; } +} diff --git a/tools/blockcheck/scaffold/units/InMemorySchedulerContext.cs b/tools/blockcheck/scaffold/units/InMemorySchedulerContext.cs new file mode 100644 index 0000000..61485a4 --- /dev/null +++ b/tools/blockcheck/scaffold/units/InMemorySchedulerContext.cs @@ -0,0 +1,88 @@ +// Types and values InMemoryScheduler.md names in its blocks and never declares. +// +// The services and tests run against a domain the page never shows: orders, analytics, reports, +// payments, and the test fixture's command. Each stub carries only the +// members a block names. +// +// blockcheck: using static InMemorySchedulerContext; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +public static class InMemorySchedulerContext +{ + // blocks 10–12: `brighter.UseScheduler(…)`, the builder `AddBrighter` returns (block 4) + public static IBrighterBuilder brighter => null!; + // block 10: `environment.IsDevelopment()` + public static IHostEnvironment environment => null!; + // blocks 13, 15, 16: `_commandProcessor.SendAsync(…)`, a field of the caller's + public static IAmACommandProcessor _commandProcessor => null!; + // blocks 13, 16: `SendAsync(…, command)` + public static IRequest command => null!; + // block 16: `SendAsync(delay, command)` + public static TimeSpan delay => default; + // block 16: `_logger.LogInformation(…)` + public static ILogger _logger => null!; + // block 16: `_serviceProvider.GetService<…>()` + public static IServiceProvider _serviceProvider => null!; +} + +// blocks 6, 8: `order.Id`, `order.ConfirmationSchedulerId`, `order.Status` +public class Order +{ + public Guid Id { get; set; } + public string? ConfirmationSchedulerId { get; set; } + public OrderStatus Status { get; set; } +} + +// block 8: `OrderStatus.Cancelled` +public enum OrderStatus { Cancelled } + +// blocks 6, 8: `repository.SaveAsync`, `GetAsync`, `UpdateAsync` +public interface IOrderRepository +{ + Task SaveAsync(Order order); + Task GetAsync(Guid orderId); + Task UpdateAsync(Order order); +} + +// block 2: `repository.SaveActionAsync(userId, action)` +public interface IAnalyticsRepository +{ + Task SaveActionAsync(string userId, string action); +} + +// block 2: `new AggregateAnalyticsCommand { UserId = userId }` +public class AggregateAnalyticsCommand() : Command(Id.Random()) +{ + public string UserId { get; set; } = string.Empty; +} + +// blocks 6, 8: `new SendOrderConfirmationCommand { OrderId = order.Id }` +public class SendOrderConfirmationCommand() : Command(Id.Random()) +{ + public Guid OrderId { get; set; } +} + +// block 7: `new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date }` +public class GenerateDailyReportCommand() : Command(Id.Random()) +{ + public DateTime Date { get; set; } +} + +// blocks 1, 9: `new TestCommand()` +public class TestCommand() : Command(Id.Random()); + +// block 15: `new ProcessPaymentCommand { Amount = 1000.00m }` +public class ProcessPaymentCommand() : Command(Id.Random()) +{ + public decimal Amount { get; set; } +} + +// block 16: `IHandleRequestsAsync` +public class YourCommand() : Command(Id.Random()); diff --git a/tools/blockcheck/scaffold/units/QuartzSchedulerContext.cs b/tools/blockcheck/scaffold/units/QuartzSchedulerContext.cs new file mode 100644 index 0000000..af03015 --- /dev/null +++ b/tools/blockcheck/scaffold/units/QuartzSchedulerContext.cs @@ -0,0 +1,72 @@ +// Types and values QuartzScheduler.md names in its blocks and never declares. +// +// Block 1 is the whole of an application's startup; the blocks after it change one part of it, +// and name the `builder` it has in scope, or the `q` and `store` that `AddQuartz(q => …)` and +// `UsePersistentStore(store => …)` pass. The services schedule work from a domain the page never +// shows. +// +// blockcheck: using static QuartzSchedulerContext; + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter; +using Quartz; + +public static class QuartzSchedulerContext +{ + // blocks 2, 3, 8, 10, 14–16: `builder.Services…`, as block 1 declares it + public static WebApplicationBuilder builder => null!; + // blocks 4–7, 17, 22, 25: the configurator `AddQuartz(q => …)` passes + public static IServiceCollectionQuartzConfigurator q => null!; + // blocks 18, 20, 21, 27, 28: the options `UsePersistentStore(store => …)` passes + public static SchedulerBuilder.PersistentStoreOptions store => null!; + // blocks 4–6, 14, 17: the job store's connection string + public static string connectionString => null!; + // block 9: the `IScheduler` block 8 resolves + public static IScheduler scheduler => null!; + // block 26: `_schedulerFactory.GetScheduler()`, `services.AddSingleton<…>()` + public static ISchedulerFactory _schedulerFactory => null!; + public static IServiceCollection services => null!; + // block 19: a service's fields and arguments + public static IAmACommandProcessor _commandProcessor => null!; + public static IOrderRepository _repository => null!; + public static TimeSpan delay => default; + public static IRequest command => null!; + public static Order order => null!; +} + +// blocks 11, 12, 19: `order.Id`, `order.ProcessSchedulerId`, `order.Status` +public class Order +{ + public Guid Id { get; set; } + public string? ProcessSchedulerId { get; set; } + public OrderStatus Status { get; set; } +} + +// block 12: `OrderStatus.Cancelled` +public enum OrderStatus { Cancelled } + +// blocks 11, 12, 19: `repository.SaveAsync`, `GetAsync`, `UpdateAsync` +public interface IOrderRepository +{ + Task SaveAsync(Order order); + Task GetAsync(Guid orderId); + Task UpdateAsync(Order order); +} + +// block 11: `new ProcessOrderCommand { OrderId = order.Id }` +public class ProcessOrderCommand() : Command(Id.Random()) +{ + public Guid OrderId { get; set; } +} + +// block 13: `new GenerateDailyReportCommand { Date = DateTime.UtcNow.Date }` +public class GenerateDailyReportCommand() : Command(Id.Random()) +{ + public DateTime Date { get; set; } +} + +// block 8: `request is MyCommand command`, `command.CorrelationId` +public class MyCommand() : Command(Id.Random()); diff --git a/tools/blockcheck/scaffold/units/SwitchingSchedulersContext.cs b/tools/blockcheck/scaffold/units/SwitchingSchedulersContext.cs new file mode 100644 index 0000000..efb9229 --- /dev/null +++ b/tools/blockcheck/scaffold/units/SwitchingSchedulersContext.cs @@ -0,0 +1,25 @@ +// Values SwitchingSchedulers.md names in its blocks and never declares. +// +// Each block is the part of an application's startup that changes when the scheduler does; the +// rest of that startup, and the values it builds, are the page's other scheduler pages' to show. +// +// blockcheck: using static SwitchingSchedulersContext; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.MessagingGateway.AzureServiceBus.ClientProvider; + +public static class SwitchingSchedulersContext +{ + // blocks 1–7: `services.AddBrighter(…)` + public static IServiceCollection services => null!; + // block 3: `config.UseSqlServerStorage(connectionString)` + public static string connectionString => null!; + // block 5: `new AwsSchedulerFactory(awsConnection, "scheduler-role")` + public static AWSMessagingGatewayConnection awsConnection => null!; + // block 6: `new AzureServiceBusSchedulerFactory(clientProvider, …)` + public static IServiceBusClientProvider clientProvider => null!; + // block 7: `configuration.GetValue("UseQuartz")` + public static IConfiguration configuration => null!; +} diff --git a/tools/symbolcheck.py b/tools/symbolcheck.py index dbbc42c..aa7b02f 100644 --- a/tools/symbolcheck.py +++ b/tools/symbolcheck.py @@ -660,7 +660,26 @@ def resolve(repo, ref, symbol): if proc.returncode > 1: raise CensusError( f'{repo}@{ref}: {proc.stderr.strip()[:160] or "git grep failed"}') - return len([line for line in proc.stdout.splitlines() if line.strip()]) + # git grep cannot tell code from a comment, so each file it names is read + # again with comments and strings out (strip_noncode, as the census reads + # pages). A name a release mentions only in a `///` is not one it ships: + # spec 018 D11, after `AddServiceActivator` resolved LIVE at 10.8.0 on two + # doc comments alone. Stripping can only lower a count, and a live API + # still has its declaration outside any comment. + word = flags[-1] == '-w' + pattern = re.compile(('(?