From 6c8216384a78ec40ae6f356b2a71ef2ba0526ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81api=C5=84ski?= Date: Wed, 11 Nov 2026 13:59:22 +0100 Subject: [PATCH] fix: return the schedule builder from job settings so README chains compile AsTransactional, WithPriority, WithRetryDelaysInSeconds and WithTimeout returned IJobBuilder, which has no ScheduleAsync / SetRecurrenceAsync, so the chains in the README ("Scheduling options", "Outbox pattern") failed with CS1061. IScheduleJobBuilder and IRecurringJobBuilder now declare the settings with their own return type. Code written against IJobBuilder (e.g. the SetRecurrenceAsync configure callback) keeps working. The new tests use the README chains as written, so they stop compiling if this breaks again. Co-Authored-By: Claude Opus 5.5 --- .../Unit/FluentJobBuilderTests.cs | 109 ++++++++++++++++++ src/RunWay/Schedule/RecurringJobBuilder.cs | 18 +++ src/RunWay/Schedule/ScheduleJobBuilder.cs | 18 +++ 3 files changed, 145 insertions(+) create mode 100644 src/RunWay.Tests/Unit/FluentJobBuilderTests.cs diff --git a/src/RunWay.Tests/Unit/FluentJobBuilderTests.cs b/src/RunWay.Tests/Unit/FluentJobBuilderTests.cs new file mode 100644 index 0000000..d3ddb6c --- /dev/null +++ b/src/RunWay.Tests/Unit/FluentJobBuilderTests.cs @@ -0,0 +1,109 @@ +using Kododo.RunWay.Core.Jobs; +using Kododo.RunWay.Core.Recurrences; +using Kododo.RunWay.Core.Store; +using Kododo.RunWay.Schedule; +using NSubstitute; +using Xunit; + +namespace Kododo.RunWay.Tests.Unit; + +/// +/// The chains from the README, written the same way, through the public : +/// if a setting stops returning the builder that has ScheduleAsync / SetRecurrenceAsync, this file no longer compiles. +/// +public class FluentJobBuilderTests +{ + private sealed class SendEmailJob + { + public required string To { get; set; } + public required string Subject { get; set; } + } + + private readonly IStore _store = Substitute.For(); + private readonly IRecurrenceCalculator _calculator = Substitute.For(); + private readonly IScheduler _scheduler; + + public FluentJobBuilderTests() + { + _store.CreateAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!); + _store.CreateWithoutTransactionAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!); + _store.FindRecurrenceAsync(Arg.Any(), Arg.Any()).Returns((Recurrence?)null); + _store.CreateRecurrenceWithoutTransactionAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!); + _store.UpdateRecurrenceWithoutTransactionAsync(Arg.Any(), Arg.Any()).Returns(c => c.Arg()!); + _calculator.CalculateNextOccurrence(Arg.Any(), Arg.Any()).Returns(DateTimeOffset.UtcNow); + _scheduler = new Scheduler(_store, _calculator, new RunWayOptions()); + } + + [Fact] + public async Task Readme_SchedulingOptions_ChainEndsWithScheduleAsync() + { + // README, "Scheduling options" + await _scheduler + .Job(new SendEmailJob { To = "user@example.com", Subject = "Hello" }) + .WithPriority(10) + .WithRetryDelaysInSeconds(10, 60, 300) + .WithTimeout(TimeSpan.FromMinutes(5)) + .ScheduleAsync(CancellationToken.None); + + await _store.Received(1).CreateAsync( + Arg.Is(j => j != null && j.Options.Priority == 10 + && j.Options.RetryDelaysInSeconds.SequenceEqual(new[] { 10, 60, 300 }) + && j.Options.Timeout == TimeSpan.FromMinutes(5)), + Arg.Any()); + } + + [Fact] + public async Task Readme_Outbox_AsTransactionalFalse_UsesAmbientTransaction() + { + // README, "Outbox pattern" + await _scheduler + .Job(new SendEmailJob { To = "user@example.com", Subject = "Order confirmed" }) + .AsTransactional(false) + .ScheduleAsync(CancellationToken.None); + + await _store.Received(1).CreateWithoutTransactionAsync(Arg.Any(), Arg.Any()); + await _store.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ChainWithScheduledTime_EndsWithScheduleAsync() + { + var at = new DateTimeOffset(2030, 1, 1, 8, 0, 0, TimeSpan.Zero); + + await _scheduler.Job(new SendEmailJob { To = "a@b.c", Subject = "Later" }) + .AsTransactional(false) + .WithPriority(3) + .ScheduleAsync(at, CancellationToken.None); + + await _store.Received(1).CreateWithoutTransactionAsync( + Arg.Is(j => j != null && j.ScheduledAt == at && j.Options.Priority == 3), Arg.Any()); + } + + [Fact] + public async Task RecurrenceChain_EndsWithSetRecurrenceAsync() + { + await _scheduler.Recurrence(new SendEmailJob { To = "a@b.c", Subject = "Report" }) + .WithTimeout(TimeSpan.FromMinutes(30)) + .WithRetryDelaysInSeconds(60, 300) + .SetRecurrenceAsync("nightly-report", "0 2 * * *", CancellationToken.None); + + await _store.Received(1).CreateRecurrenceWithoutTransactionAsync( + Arg.Is(r => r != null && r.Options.Timeout == TimeSpan.FromMinutes(30) + && r.Options.RetryDelaysInSeconds.SequenceEqual(new[] { 60, 300 })), + Arg.Any()); + } + + [Fact] + public async Task SettingsThroughTheBaseInterface_StillApply() + { + // Code written against IJobBuilder (e.g. the SetRecurrenceAsync configure callback) keeps working. + var builder = _scheduler.Job(new SendEmailJob { To = "a@b.c", Subject = "Base" }); + IJobBuilder configure = builder; + configure.WithPriority(7).WithTimeout(TimeSpan.FromSeconds(30)); + + await builder.ScheduleAsync(CancellationToken.None); + + await _store.Received(1).CreateAsync( + Arg.Is(j => j != null && j.Options.Priority == 7 && j.Options.Timeout == TimeSpan.FromSeconds(30)), Arg.Any()); + } +} diff --git a/src/RunWay/Schedule/RecurringJobBuilder.cs b/src/RunWay/Schedule/RecurringJobBuilder.cs index ca2e74a..01a09d1 100644 --- a/src/RunWay/Schedule/RecurringJobBuilder.cs +++ b/src/RunWay/Schedule/RecurringJobBuilder.cs @@ -5,6 +5,12 @@ namespace Kododo.RunWay.Schedule; public interface IRecurringJobBuilder : IJobBuilder { + // The settings return the recurring builder, so a chain can end with SetRecurrenceAsync. + new IRecurringJobBuilder AsTransactional(bool transactional); + new IRecurringJobBuilder WithPriority(int priority); + new IRecurringJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds); + new IRecurringJobBuilder WithTimeout(TimeSpan timeout); + Task SetRecurrenceAsync(string key, string cronExpression, CancellationToken stoppingToken); } @@ -23,6 +29,18 @@ public RecurringJobBuilder(T data, IStore store, IRecurrenceCalculator recurrenc _options = options; } + public new IRecurringJobBuilder AsTransactional(bool transactional) { base.AsTransactional(transactional); return this; } + + public new IRecurringJobBuilder WithPriority(int priority) { base.WithPriority(priority); return this; } + + public new IRecurringJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds) + { + base.WithRetryDelaysInSeconds(retryDelaysInSeconds); + return this; + } + + public new IRecurringJobBuilder WithTimeout(TimeSpan timeout) { base.WithTimeout(timeout); return this; } + public async Task SetRecurrenceAsync(string key, string cronExpression, CancellationToken stoppingToken) { var recurrenceId = new RecurrenceId(key); diff --git a/src/RunWay/Schedule/ScheduleJobBuilder.cs b/src/RunWay/Schedule/ScheduleJobBuilder.cs index 508857d..fce3869 100644 --- a/src/RunWay/Schedule/ScheduleJobBuilder.cs +++ b/src/RunWay/Schedule/ScheduleJobBuilder.cs @@ -5,6 +5,12 @@ namespace Kododo.RunWay.Schedule; public interface IScheduleJobBuilder : IJobBuilder { + // The settings return the schedule builder, so a chain can end with ScheduleAsync. + new IScheduleJobBuilder AsTransactional(bool transactional); + new IScheduleJobBuilder WithPriority(int priority); + new IScheduleJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds); + new IScheduleJobBuilder WithTimeout(TimeSpan timeout); + Task ScheduleAsync(DateTimeOffset scheduledAt, CancellationToken stoppingToken); Task ScheduleAsync(CancellationToken stoppingToken); } @@ -22,6 +28,18 @@ public ScheduleJobBuilder(T data, IStore store, RunWayOptions options) : base(op _options = options; } + public new IScheduleJobBuilder AsTransactional(bool transactional) { base.AsTransactional(transactional); return this; } + + public new IScheduleJobBuilder WithPriority(int priority) { base.WithPriority(priority); return this; } + + public new IScheduleJobBuilder WithRetryDelaysInSeconds(params int[] retryDelaysInSeconds) + { + base.WithRetryDelaysInSeconds(retryDelaysInSeconds); + return this; + } + + public new IScheduleJobBuilder WithTimeout(TimeSpan timeout) { base.WithTimeout(timeout); return this; } + public async Task ScheduleAsync(DateTimeOffset scheduledAt, CancellationToken stoppingToken) { var jobData = _options.JobDataSerializer.Serialize(_data);