diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 1feeda15..26c2e80d 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -85,6 +85,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.AzureManaged.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleAppMinimal", "samples\ConsoleAppMinimal\ConsoleAppMinimal.csproj", "{B48FACA9-A328-452A-BFAE-C4F60F9C7024}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScheduledTasks", "src\ScheduledTasks\ScheduledTasks.csproj", "{69ED743C-D616-4530-87E2-391D249D7368}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScheduleConsoleApp", "samples\ScheduleConsoleApp\ScheduleConsoleApp.csproj", "{A89B766C-987F-4C9F-8937-D0AB9FE640C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScheduleWebApp", "samples\ScheduleWebApp\ScheduleWebApp.csproj", "{100348B5-4D97-4A3F-B777-AB14F276F8FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScheduledTasks.Tests", "test\ScheduledTasks.Tests\ScheduledTasks.Tests.csproj", "{D2779F32-A548-44F8-B60A-6AC018966C79}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -223,6 +231,22 @@ Global {B48FACA9-A328-452A-BFAE-C4F60F9C7024}.Debug|Any CPU.Build.0 = Debug|Any CPU {B48FACA9-A328-452A-BFAE-C4F60F9C7024}.Release|Any CPU.ActiveCfg = Release|Any CPU {B48FACA9-A328-452A-BFAE-C4F60F9C7024}.Release|Any CPU.Build.0 = Release|Any CPU + {69ED743C-D616-4530-87E2-391D249D7368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {69ED743C-D616-4530-87E2-391D249D7368}.Debug|Any CPU.Build.0 = Debug|Any CPU + {69ED743C-D616-4530-87E2-391D249D7368}.Release|Any CPU.ActiveCfg = Release|Any CPU + {69ED743C-D616-4530-87E2-391D249D7368}.Release|Any CPU.Build.0 = Release|Any CPU + {A89B766C-987F-4C9F-8937-D0AB9FE640C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A89B766C-987F-4C9F-8937-D0AB9FE640C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A89B766C-987F-4C9F-8937-D0AB9FE640C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A89B766C-987F-4C9F-8937-D0AB9FE640C8}.Release|Any CPU.Build.0 = Release|Any CPU + {100348B5-4D97-4A3F-B777-AB14F276F8FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {100348B5-4D97-4A3F-B777-AB14F276F8FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {100348B5-4D97-4A3F-B777-AB14F276F8FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {100348B5-4D97-4A3F-B777-AB14F276F8FE}.Release|Any CPU.Build.0 = Release|Any CPU + {D2779F32-A548-44F8-B60A-6AC018966C79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2779F32-A548-44F8-B60A-6AC018966C79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -265,6 +289,10 @@ Global {CECADDB5-E30A-4CE2-8604-9AC596D4A2DC} = {E5637F81-2FB9-4CD7-900D-455363B142A7} {3272C041-F81D-4C85-A4FB-2A700B5A7A9D} = {CECADDB5-E30A-4CE2-8604-9AC596D4A2DC} {B48FACA9-A328-452A-BFAE-C4F60F9C7024} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {69ED743C-D616-4530-87E2-391D249D7368} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} + {A89B766C-987F-4C9F-8937-D0AB9FE640C8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {100348B5-4D97-4A3F-B777-AB14F276F8FE} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {D2779F32-A548-44F8-B60A-6AC018966C79} = {E5637F81-2FB9-4CD7-900D-455363B142A7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/samples/ScheduleConsoleApp/Activities/GetStockPrice.cs b/samples/ScheduleConsoleApp/Activities/GetStockPrice.cs new file mode 100644 index 00000000..63db17f7 --- /dev/null +++ b/samples/ScheduleConsoleApp/Activities/GetStockPrice.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace ScheduleConsoleApp.Activities; + +[DurableTask] +public class GetStockPrice : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string symbol) + { + // Mock implementation - would normally call stock API + return Task.FromResult(100.00m); + } +} diff --git a/samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs b/samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs new file mode 100644 index 00000000..3cc04a60 --- /dev/null +++ b/samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging; + +[DurableTask] +public class StockPriceOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string symbol) + { + var logger = context.CreateReplaySafeLogger("DemoOrchestration"); + logger.LogInformation("Getting stock price for: {symbol}", symbol); + try + { + // Get current stock price + decimal currentPrice = await context.CallGetStockPriceAsync(symbol); + + logger.LogInformation("Current price for {symbol} is ${price:F2}", symbol, currentPrice); + + return $"Stock {symbol} price: ${currentPrice:F2} at {DateTime.UtcNow}"; + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing stock price for {symbol}", symbol); + throw; + } + } +} \ No newline at end of file diff --git a/samples/ScheduleConsoleApp/Program.cs b/samples/ScheduleConsoleApp/Program.cs new file mode 100644 index 00000000..f2ad4346 --- /dev/null +++ b/samples/ScheduleConsoleApp/Program.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.ScheduledTasks; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ScheduleConsoleApp; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Get configuration +string connectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException("Missing required configuration 'DURABLE_TASK_SCHEDULER_CONNECTION_STRING'"); + +// Configure the worker +builder.Services.AddDurableTaskWorker(builder => +{ + // Add the Schedule entity and demo orchestration + builder.AddTasks(r => r.AddAllGeneratedTasks()); + + // Enable scheduled tasks support + builder.UseDurableTaskScheduler(connectionString); + builder.UseScheduledTasks(); +}); + +// Configure the client +builder.Services.AddDurableTaskClient(builder => +{ + builder.UseDurableTaskScheduler(connectionString); + builder.UseScheduledTasks(); +}); + +// Configure console logging +builder.Services.AddLogging(logging => +{ + logging.AddSimpleConsole(options => + { + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; + }); +}); + +IHost host = builder.Build(); +await host.StartAsync(); + +// Run the schedule operations +ScheduledTaskClient scheduledTaskClient = host.Services.GetRequiredService(); +await ScheduleDemo.RunDemoAsync(scheduledTaskClient); + +await host.StopAsync(); \ No newline at end of file diff --git a/samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj b/samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj new file mode 100644 index 00000000..7efdf0fc --- /dev/null +++ b/samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + + + + + + + + + + + + + + + diff --git a/samples/ScheduleConsoleApp/ScheduleDemo.cs b/samples/ScheduleConsoleApp/ScheduleDemo.cs new file mode 100644 index 00000000..712479d3 --- /dev/null +++ b/samples/ScheduleConsoleApp/ScheduleDemo.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.ScheduledTasks; + +namespace ScheduleConsoleApp; + +/// +/// Demonstrates various schedule operations in a sample application. +/// +static class ScheduleDemo +{ + public static async Task RunDemoAsync(ScheduledTaskClient scheduledTaskClient) + { + ArgumentNullException.ThrowIfNull(scheduledTaskClient); + + try + { + await DeleteExistingSchedulesAsync(scheduledTaskClient); + await CreateAndManageScheduleAsync(scheduledTaskClient); + } + catch (Exception ex) + { + Console.WriteLine($"One of your schedule operations failed, please fix and rerun: {ex.Message}"); + } + } + + static async Task DeleteExistingSchedulesAsync(ScheduledTaskClient scheduledTaskClient) + { + // Define the initial query with the desired page size + ScheduleQuery query = new ScheduleQuery { PageSize = 100 }; + + // Retrieve the pageable collection of schedule IDs + AsyncPageable schedules = scheduledTaskClient.ListSchedulesAsync(query); + + // Delete each existing schedule + await foreach (ScheduleDescription schedule in schedules) + { + ScheduleClient scheduleClient = scheduledTaskClient.GetScheduleClient(schedule.ScheduleId); + await scheduleClient.DeleteAsync(); + Console.WriteLine($"Deleted schedule {schedule.ScheduleId}"); + } + } + + static async Task CreateAndManageScheduleAsync(ScheduledTaskClient scheduledTaskClient) + { + // Create schedule options that runs every 4 seconds + ScheduleCreationOptions scheduleOptions = new ScheduleCreationOptions( + "demo-schedule101", + nameof(StockPriceOrchestrator), + TimeSpan.FromSeconds(4)) + { + StartAt = DateTimeOffset.UtcNow, + OrchestrationInput = "MSFT" + }; + + // Create the schedule and get a handle to it + ScheduleClient scheduleClient = await scheduledTaskClient.CreateScheduleAsync(scheduleOptions); + + // Get and print the initial schedule description + await PrintScheduleDescriptionAsync(scheduleClient); + + // Pause the schedule + Console.WriteLine("\nPausing schedule..."); + await scheduleClient.PauseAsync(); + await PrintScheduleDescriptionAsync(scheduleClient); + + // Resume the schedule + Console.WriteLine("\nResuming schedule..."); + await scheduleClient.ResumeAsync(); + await PrintScheduleDescriptionAsync(scheduleClient); + + // Wait for a while to let the schedule run + await Task.Delay(TimeSpan.FromMinutes(30)); + } + + static async Task PrintScheduleDescriptionAsync(ScheduleClient scheduleClient) + { + ScheduleDescription scheduleDescription = await scheduleClient.DescribeAsync(); + Console.WriteLine(scheduleDescription); + Console.WriteLine("\n\n"); + } +} \ No newline at end of file diff --git a/samples/ScheduleConsoleApp/appsettings.json b/samples/ScheduleConsoleApp/appsettings.json new file mode 100644 index 00000000..034ac402 --- /dev/null +++ b/samples/ScheduleConsoleApp/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning", + "ScheduleConsoleApp": "Debug", + "DemoOrchestration": "Debug" + } + } +} \ No newline at end of file diff --git a/samples/ScheduleWebApp/Models/CreateScheduleRequest.cs b/samples/ScheduleWebApp/Models/CreateScheduleRequest.cs new file mode 100644 index 00000000..08c1c0e0 --- /dev/null +++ b/samples/ScheduleWebApp/Models/CreateScheduleRequest.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace ScheduleWebApp.Models; + +/// +/// Represents a request to create a new schedule. +/// +public class CreateScheduleRequest +{ + /// + /// Gets or sets the unique identifier for the schedule. + /// + public string Id { get; set; } = default!; + + /// + /// Gets or sets the name of the orchestration to be scheduled. + /// + public string OrchestrationName { get; set; } = default!; + + /// + /// Gets or sets the input data for the orchestration. + /// + public string? Input { get; set; } + + /// + /// Gets or sets the time interval between schedule executions. + /// + public TimeSpan Interval { get; set; } + + /// + /// Gets or sets the time when the schedule should start. + /// + public DateTimeOffset? StartAt { get; set; } + + /// + /// Gets or sets the time when the schedule should end. + /// + public DateTimeOffset? EndAt { get; set; } +} diff --git a/samples/ScheduleWebApp/Models/UpdateScheduleRequest.cs b/samples/ScheduleWebApp/Models/UpdateScheduleRequest.cs new file mode 100644 index 00000000..371af1e3 --- /dev/null +++ b/samples/ScheduleWebApp/Models/UpdateScheduleRequest.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace ScheduleWebApp.Models; + +/// +/// Represents a request to update an existing schedule. +/// +public class UpdateScheduleRequest +{ + /// + /// Gets or sets the name of the orchestration to be scheduled. + /// + public string OrchestrationName { get; set; } = default!; + + /// + /// Gets or sets the input data for the orchestration. + /// + public string? Input { get; set; } + + /// + /// Gets or sets the time interval between schedule executions. + /// + public TimeSpan Interval { get; set; } + + /// + /// Gets or sets the time when the schedule should start. + /// + public DateTimeOffset? StartAt { get; set; } + + /// + /// Gets or sets the time when the schedule should end. + /// + public DateTimeOffset? EndAt { get; set; } +} diff --git a/samples/ScheduleWebApp/Orchestrations/CacheClearingOrchestrator.cs b/samples/ScheduleWebApp/Orchestrations/CacheClearingOrchestrator.cs new file mode 100644 index 00000000..4113f07a --- /dev/null +++ b/samples/ScheduleWebApp/Orchestrations/CacheClearingOrchestrator.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace ScheduleWebApp.Orchestrations; + +public class CacheClearingOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string scheduleId) + { + ILogger logger = context.CreateReplaySafeLogger(nameof(CacheClearingOrchestrator)); + try + { + logger.LogInformation("Starting CacheClearingOrchestration for schedule ID: {ScheduleId}", scheduleId); + + // Simulate cache clearing + await Task.Delay(TimeSpan.FromSeconds(5)); + + logger.LogInformation("CacheClearingOrchestration completed for schedule ID: {ScheduleId}", scheduleId); + + return "ok"; + } + catch (Exception ex) + { + logger.LogError(ex, "Error in CacheClearingOrchestration for schedule ID: {ScheduleId}", scheduleId); + throw; + } + } +} \ No newline at end of file diff --git a/samples/ScheduleWebApp/Program.cs b/samples/ScheduleWebApp/Program.cs new file mode 100644 index 00000000..3495af0b --- /dev/null +++ b/samples/ScheduleWebApp/Program.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.ScheduledTasks; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using ScheduleWebApp.Orchestrations; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +string connectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException("Missing required configuration 'DURABLE_TASK_SCHEDULER_CONNECTION_STRING'"); + +// Add all the generated orchestrations and activities automatically +builder.Services.AddDurableTaskWorker(builder => +{ + builder.AddTasks(r => + { + // Add your orchestrators and activities here + r.AddOrchestrator(); + }); + builder.UseDurableTaskScheduler(connectionString); + builder.UseScheduledTasks(); +}); + +// Register the client, which can be used to start orchestrations +builder.Services.AddDurableTaskClient(builder => +{ + builder.UseDurableTaskScheduler(connectionString); + builder.UseScheduledTasks(); +}); + +// Configure console logging using the simpler, more compact format +builder.Services.AddLogging(logging => +{ + logging.AddSimpleConsole(options => + { + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; + }); +}); + +// Configure the HTTP request pipeline +builder.Services.AddControllers().AddJsonOptions(options => +{ + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + +// The actual listen URL can be configured in environment variables named "ASPNETCORE_URLS" or "ASPNETCORE_URLS_HTTPS" +WebApplication app = builder.Build(); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/samples/ScheduleWebApp/Properties/launchSettings.json b/samples/ScheduleWebApp/Properties/launchSettings.json new file mode 100644 index 00000000..f2acfbff --- /dev/null +++ b/samples/ScheduleWebApp/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:47697", + "sslPort": 44371 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "applicationUrl": "http://localhost:5008", + "dotnetRunMessages": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "" + } + } + } +} diff --git a/samples/ScheduleWebApp/ScheduleController.cs b/samples/ScheduleWebApp/ScheduleController.cs new file mode 100644 index 00000000..f320723b --- /dev/null +++ b/samples/ScheduleWebApp/ScheduleController.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.DurableTask; +using Microsoft.DurableTask.ScheduledTasks; +using ScheduleWebApp.Models; + +namespace ScheduleWebApp.Controllers; + +/// +/// Controller for managing scheduled tasks through a REST API. +/// Provides endpoints for creating, reading, updating, and deleting schedules, +/// as well as pausing and resuming them. +/// +[ApiController] +[Route("schedules")] +public class ScheduleController : ControllerBase +{ + readonly ScheduledTaskClient scheduledTaskClient; + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// Client for managing scheduled tasks. + /// Logger for recording controller operations. + public ScheduleController( + ScheduledTaskClient scheduledTaskClient, + ILogger logger) + { + this.scheduledTaskClient = scheduledTaskClient ?? throw new ArgumentNullException(nameof(scheduledTaskClient)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Creates a new schedule based on the provided configuration. + /// + /// The schedule configuration to create. + /// The created schedule description. + [HttpPost] + public async Task> CreateSchedule([FromBody] CreateScheduleRequest createScheduleRequest) + { + if (createScheduleRequest == null) + { + return this.BadRequest("createScheduleRequest cannot be null"); + } + + try + { + ScheduleCreationOptions creationOptions = new ScheduleCreationOptions(createScheduleRequest.Id.ToString(), createScheduleRequest.OrchestrationName, createScheduleRequest.Interval) + { + OrchestrationInput = createScheduleRequest.Input, + StartAt = createScheduleRequest.StartAt, + EndAt = createScheduleRequest.EndAt, + StartImmediatelyIfLate = true + }; + + ScheduleClient scheduleClient = await this.scheduledTaskClient.CreateScheduleAsync(creationOptions); + ScheduleDescription description = await scheduleClient.DescribeAsync(); + + this.logger.LogInformation("Created new schedule with ID: {ScheduleId}", createScheduleRequest.Id); + + return this.CreatedAtAction(nameof(GetSchedule), new { id = createScheduleRequest.Id }, description); + } + catch (ScheduleClientValidationException ex) + { + this.logger.LogError(ex, "Validation failed while creating schedule {ScheduleId}", createScheduleRequest.Id); + return this.BadRequest(ex.Message); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error creating schedule {ScheduleId}", createScheduleRequest.Id); + return this.StatusCode(500, "An error occurred while creating the schedule"); + } + } + + /// + /// Retrieves a specific schedule by its ID. + /// + /// The ID of the schedule to retrieve. + /// The schedule description if found. + [HttpGet("{id}")] + public async Task> GetSchedule(string id) + { + try + { + ScheduleDescription? schedule = await this.scheduledTaskClient.GetScheduleAsync(id); + return this.Ok(schedule); + } + catch (ScheduleNotFoundException) + { + return this.NotFound(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error retrieving schedule {ScheduleId}", id); + return this.StatusCode(500, "An error occurred while retrieving the schedule"); + } + } + + /// + /// Lists all schedules, optionally filtered by status. + /// + /// A collection of schedule descriptions. + [HttpGet("list")] + public async Task>> ListSchedules() + { + try + { + AsyncPageable schedules = this.scheduledTaskClient.ListSchedulesAsync(); + + // add schedule result list + List scheduleList = new List(); + // Initialize the continuation token + await foreach (ScheduleDescription schedule in schedules) + { + scheduleList.Add(schedule); + } + + return this.Ok(scheduleList); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error retrieving schedules"); + return this.StatusCode(500, "An error occurred while retrieving schedules"); + } + } + + /// + /// Updates an existing schedule with new configuration. + /// + /// The ID of the schedule to update. + /// The new schedule configuration. + /// The updated schedule description. + [HttpPut("{id}")] + public async Task> UpdateSchedule(string id, [FromBody] UpdateScheduleRequest updateScheduleRequest) + { + if (updateScheduleRequest == null) + { + return this.BadRequest("Schedule cannot be null"); + } + + try + { + ScheduleClient scheduleClient = this.scheduledTaskClient.GetScheduleClient(id); + + ScheduleUpdateOptions updateOptions = new ScheduleUpdateOptions + { + OrchestrationName = updateScheduleRequest.OrchestrationName, + OrchestrationInput = updateScheduleRequest.Input, + StartAt = updateScheduleRequest.StartAt, + EndAt = updateScheduleRequest.EndAt, + Interval = updateScheduleRequest.Interval + }; + + await scheduleClient.UpdateAsync(updateOptions); + return this.Ok(await scheduleClient.DescribeAsync()); + } + catch (ScheduleNotFoundException) + { + return this.NotFound(); + } + catch (ScheduleClientValidationException ex) + { + this.logger.LogError(ex, "Validation failed while updating schedule {ScheduleId}", id); + return this.BadRequest(ex.Message); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error updating schedule {ScheduleId}", id); + return this.StatusCode(500, "An error occurred while updating the schedule"); + } + } + + /// + /// Deletes a schedule by its ID. + /// + /// The ID of the schedule to delete. + /// No content if successful. + [HttpDelete("{id}")] + public async Task DeleteSchedule(string id) + { + try + { + ScheduleClient scheduleClient = this.scheduledTaskClient.GetScheduleClient(id); + await scheduleClient.DeleteAsync(); + return this.NoContent(); + } + catch (ScheduleNotFoundException) + { + return this.NotFound(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error deleting schedule {ScheduleId}", id); + return this.StatusCode(500, "An error occurred while deleting the schedule"); + } + } + + /// + /// Pauses a running schedule. + /// + /// The ID of the schedule to pause. + /// The updated schedule description. + [HttpPost("{id}/pause")] + public async Task> PauseSchedule(string id) + { + try + { + ScheduleClient scheduleClient = this.scheduledTaskClient.GetScheduleClient(id); + await scheduleClient.PauseAsync(); + return this.Ok(await scheduleClient.DescribeAsync()); + } + catch (ScheduleNotFoundException) + { + return this.NotFound(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error pausing schedule {ScheduleId}", id); + return this.StatusCode(500, "An error occurred while pausing the schedule"); + } + } + + /// + /// Resumes a paused schedule. + /// + /// The ID of the schedule to resume. + /// The updated schedule description. + [HttpPost("{id}/resume")] + public async Task> ResumeSchedule(string id) + { + try + { + ScheduleClient scheduleClient = this.scheduledTaskClient.GetScheduleClient(id); + await scheduleClient.ResumeAsync(); + return this.Ok(await scheduleClient.DescribeAsync()); + } + catch (ScheduleNotFoundException) + { + return this.NotFound(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error resuming schedule {ScheduleId}", id); + return this.StatusCode(500, "An error occurred while resuming the schedule"); + } + } +} \ No newline at end of file diff --git a/samples/ScheduleWebApp/ScheduleWebApp.csproj b/samples/ScheduleWebApp/ScheduleWebApp.csproj new file mode 100644 index 00000000..8206fa42 --- /dev/null +++ b/samples/ScheduleWebApp/ScheduleWebApp.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + true + $(BaseIntermediateOutputPath)Generated + + + + + + + + + + + + + + diff --git a/samples/ScheduleWebApp/ScheduleWebApp.http b/samples/ScheduleWebApp/ScheduleWebApp.http new file mode 100644 index 00000000..f5672e1b --- /dev/null +++ b/samples/ScheduleWebApp/ScheduleWebApp.http @@ -0,0 +1,54 @@ +### Variables +@baseUrl = http://localhost:5008 +@scheduleId = schedule-123 + +### Create a new schedule +# @name createSchedule +POST {{baseUrl}}/schedules +Content-Type: application/json + +{ + "id": "{{scheduleId}}", + "orchestrationName": "CacheClearingOrchestrator", + "input": "{{scheduleId}}", + "interval": "00:00:10" +} + +### Get a specific schedule by ID +# Note: This endpoint can be used to verify the schedule was created +# The ID in the URL should match the id used in create request +GET {{baseUrl}}/schedules/{{scheduleId}} + +### List all schedules +GET {{baseUrl}}/schedules/list + +### Update an existing schedule +PUT {{baseUrl}}/schedules/{{scheduleId}} +Content-Type: application/json + +{ + "orchestrationName": "CacheClearingOrchestrator", + "interval": "00:00:20" +} + +### Pause a schedule +POST {{baseUrl}}/schedules/{{scheduleId}}/pause + +### Resume a schedule +POST {{baseUrl}}/schedules/{{scheduleId}}/resume + +### Delete a schedule +DELETE {{baseUrl}}/schedules/{{scheduleId}} + +### Tips: +# - Replace the baseUrl variable if your application runs on a different port +# - The scheduleId variable can be changed to test different schedule instances +# - Time intervals use TimeSpan format (hh:mm:ss) +# Examples: +# - "00:00:10" = 10 seconds +# - "00:30:00" = 30 minutes +# - "01:00:00" = 1 hour +# - "24:00:00" = 1 day +# - Dates are in ISO 8601 format (YYYY-MM-DDThh:mm:ssZ) +# - You can use the REST Client extension in VS Code to execute these requests +# - The @name directive allows referencing the response in subsequent requests diff --git a/samples/ScheduleWebApp/appsettings.Development.json b/samples/ScheduleWebApp/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/ScheduleWebApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/ScheduleWebApp/appsettings.json b/samples/ScheduleWebApp/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/ScheduleWebApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index ad90d91f..ca514f29 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,2 +1,2 @@ -# The following files were downloaded from branch main at 2025-02-11 19:40:58 UTC +# The following files were downloaded from branch main at 2025-02-19 06:25:02 UTC https://raw.githubusercontent.com/microsoft/durabletask-protobuf/589cb5ecd9dd4b1fe463750defa3e2c84276b079/protos/orchestrator_service.proto diff --git a/src/ScheduledTasks/Client/DefaultScheduleClient.cs b/src/ScheduledTasks/Client/DefaultScheduleClient.cs new file mode 100644 index 00000000..1a3bb44e --- /dev/null +++ b/src/ScheduledTasks/Client/DefaultScheduleClient.cs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Represents a handle to a scheduled task, providing operations for managing the schedule. +/// +// TODO: Isolate system entity from user entities +class DefaultScheduleClient : ScheduleClient +{ + readonly DurableTaskClient durableTaskClient; + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The durable task client. + /// The ID of the schedule. + /// The logger. + /// Thrown if or is null. + public DefaultScheduleClient(DurableTaskClient client, string scheduleId, ILogger logger) + : base(scheduleId) + { + this.durableTaskClient = client ?? throw new ArgumentNullException(nameof(client)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.EntityId = new EntityInstanceId(nameof(Schedule), this.ScheduleId); + } + + /// + /// Gets the entity ID of the schedule. + /// + EntityInstanceId EntityId { get; } + + /// + public override async Task CreateAsync(ScheduleCreationOptions creationOptions, CancellationToken cancellation = default) + { + try + { + Check.NotNull(creationOptions, nameof(creationOptions)); + + ScheduleOperationRequest request = new ScheduleOperationRequest(this.EntityId, nameof(Schedule.CreateSchedule), creationOptions); + string instanceId = await this.durableTaskClient.ScheduleNewOrchestrationInstanceAsync( + new TaskName(nameof(ExecuteScheduleOperationOrchestrator)), + request, + cancellation); + + // Wait for the orchestration to complete + OrchestrationMetadata state = await this.durableTaskClient.WaitForInstanceCompletionAsync(instanceId, true, cancellation); + + if (state.RuntimeStatus != OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException($"Failed to create schedule '{this.ScheduleId}': {state.FailureDetails?.ErrorMessage ?? string.Empty}"); + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.CreateAsync), this.ScheduleId, ex); + + throw; + } + } + + /// + public override async Task DescribeAsync(CancellationToken cancellation = default) + { + try + { + Check.NotNullOrEmpty(this.ScheduleId, nameof(this.ScheduleId)); + + EntityMetadata? metadata = + await this.durableTaskClient.Entities.GetEntityAsync(this.EntityId, cancellation: cancellation); + if (metadata == null) + { + throw new ScheduleNotFoundException(this.ScheduleId); + } + + ScheduleState state = metadata.State; + + ScheduleConfiguration? config = state.ScheduleConfiguration; + + return new ScheduleDescription + { + ScheduleId = this.ScheduleId, + OrchestrationName = config?.OrchestrationName, + OrchestrationInput = config?.OrchestrationInput, + OrchestrationInstanceId = config?.OrchestrationInstanceId, + StartAt = config?.StartAt, + EndAt = config?.EndAt, + Interval = config?.Interval, + StartImmediatelyIfLate = config?.StartImmediatelyIfLate, + Status = state.Status, + ExecutionToken = state.ExecutionToken, + LastRunAt = state.LastRunAt, + NextRunAt = state.NextRunAt, + }; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.DescribeAsync), this.ScheduleId, ex); + + throw; + } + } + + /// + public override async Task PauseAsync(CancellationToken cancellation = default) + { + try + { + this.logger.ClientPausingSchedule(this.ScheduleId); + + ScheduleOperationRequest request = new ScheduleOperationRequest(this.EntityId, nameof(Schedule.PauseSchedule)); + string instanceId = await this.durableTaskClient.ScheduleNewOrchestrationInstanceAsync( + new TaskName(nameof(ExecuteScheduleOperationOrchestrator)), + request, + cancellation); + + // Wait for the orchestration to complete + OrchestrationMetadata state = await this.durableTaskClient.WaitForInstanceCompletionAsync(instanceId, true, cancellation); + + if (state.RuntimeStatus != OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException($"Failed to pause schedule '{this.ScheduleId}': {state.FailureDetails?.ErrorMessage ?? string.Empty}"); + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.PauseAsync), this.ScheduleId, ex); + + throw; + } + } + + /// + public override async Task ResumeAsync(CancellationToken cancellation = default) + { + try + { + this.logger.ClientResumingSchedule(this.ScheduleId); + + ScheduleOperationRequest request = new ScheduleOperationRequest(this.EntityId, nameof(Schedule.ResumeSchedule)); + string instanceId = await this.durableTaskClient.ScheduleNewOrchestrationInstanceAsync( + new TaskName(nameof(ExecuteScheduleOperationOrchestrator)), + request, + cancellation); + + // Wait for the orchestration to complete + OrchestrationMetadata state = await this.durableTaskClient.WaitForInstanceCompletionAsync(instanceId, true, cancellation); + + if (state.RuntimeStatus != OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException($"Failed to resume schedule '{this.ScheduleId}': {state.FailureDetails?.ErrorMessage ?? string.Empty}"); + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.ResumeAsync), this.ScheduleId, ex); + + throw; + } + } + + /// + public override async Task UpdateAsync(ScheduleUpdateOptions updateOptions, CancellationToken cancellation = default) + { + try + { + Check.NotNull(updateOptions, nameof(updateOptions)); + this.logger.ClientUpdatingSchedule(this.ScheduleId); + + ScheduleOperationRequest request = new ScheduleOperationRequest(this.EntityId, nameof(Schedule.UpdateSchedule), updateOptions); + string instanceId = await this.durableTaskClient.ScheduleNewOrchestrationInstanceAsync( + new TaskName(nameof(ExecuteScheduleOperationOrchestrator)), + request, + cancellation); + + // Wait for the orchestration to complete + OrchestrationMetadata state = await this.durableTaskClient.WaitForInstanceCompletionAsync(instanceId, true, cancellation); + + if (state.RuntimeStatus != OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException($"Failed to update schedule '{this.ScheduleId}': {state.FailureDetails?.ErrorMessage ?? string.Empty}"); + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.UpdateAsync), this.ScheduleId, ex); + + throw; + } + } + + /// + public override async Task DeleteAsync(CancellationToken cancellation = default) + { + try + { + this.logger.ClientDeletingSchedule(this.ScheduleId); + + ScheduleOperationRequest request = new ScheduleOperationRequest(this.EntityId, "delete"); + string instanceId = await this.durableTaskClient.ScheduleNewOrchestrationInstanceAsync( + new TaskName(nameof(ExecuteScheduleOperationOrchestrator)), + request, + cancellation); + + // Wait for the orchestration to complete + OrchestrationMetadata state = await this.durableTaskClient.WaitForInstanceCompletionAsync(instanceId, true, cancellation); + + if (state.RuntimeStatus != OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException($"Failed to delete schedule '{this.ScheduleId}': {state.FailureDetails?.ErrorMessage ?? string.Empty}"); + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.DeleteAsync), this.ScheduleId, ex); + + throw; + } + } +} diff --git a/src/ScheduledTasks/Client/DefaultScheduledTaskClient.cs b/src/ScheduledTasks/Client/DefaultScheduledTaskClient.cs new file mode 100644 index 00000000..9ef85d0f --- /dev/null +++ b/src/ScheduledTasks/Client/DefaultScheduledTaskClient.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Client for managing scheduled tasks in a Durable Task application. +/// +class DefaultScheduledTaskClient(DurableTaskClient durableTaskClient, ILogger logger) : ScheduledTaskClient +{ + readonly DurableTaskClient durableTaskClient = Check.NotNull(durableTaskClient, nameof(durableTaskClient)); + readonly ILogger logger = Check.NotNull(logger, nameof(logger)); + + /// + public override async Task CreateScheduleAsync(ScheduleCreationOptions creationOptions, CancellationToken cancellation = default) + { + Check.NotNull(creationOptions, nameof(creationOptions)); + this.logger.ClientCreatingSchedule(creationOptions); + + try + { + // Create schedule client instance + ScheduleClient scheduleClient = new DefaultScheduleClient(this.durableTaskClient, creationOptions.ScheduleId, this.logger); + + // Create the schedule using the client + await scheduleClient.CreateAsync(creationOptions, cancellation); + + return scheduleClient; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.CreateScheduleAsync), creationOptions.ScheduleId, ex); + + throw; + } + } + + /// + public override async Task GetScheduleAsync(string scheduleId, CancellationToken cancellation = default) + { + Check.NotNullOrEmpty(scheduleId, nameof(scheduleId)); + + try + { + // Get schedule client first + ScheduleClient scheduleClient = this.GetScheduleClient(scheduleId); + + // Call DescribeAsync which handles all the entity state mapping + return await scheduleClient.DescribeAsync(cancellation); + } + catch (ScheduleNotFoundException) + { + // Return null if schedule not found + return null; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.GetScheduleAsync), scheduleId, ex); + + throw; + } + } + + /// + public override ScheduleClient GetScheduleClient(string scheduleId) + { + Check.NotNullOrEmpty(scheduleId, nameof(scheduleId)); + return new DefaultScheduleClient(this.durableTaskClient, scheduleId, this.logger); + } + + /// + // TODO: This may returned underfilled or empty pages, since you are applying a filter to the pages that are returned by the entity query. We can probably live with that, given that I don't think there is a simple solution. + public override AsyncPageable ListSchedulesAsync(ScheduleQuery? filter = null) + { + // Create an async pageable using the Pageable.Create helper + return Pageable.Create(async (continuationToken, pageSize, cancellation) => + { + try + { + // TODO: map to entity query last modified from/to filters + EntityQuery query = new EntityQuery + { + InstanceIdStartsWith = $"@{nameof(Schedule)}@{filter?.ScheduleIdPrefix ?? string.Empty}", + IncludeState = true, + PageSize = filter?.PageSize ?? ScheduleQuery.DefaultPageSize, + ContinuationToken = continuationToken, + }; + + // Get one page of entities + IAsyncEnumerable>> entityPages = + this.durableTaskClient.Entities.GetAllEntitiesAsync(query).AsPages(); + + await foreach (Page> entityPage in entityPages) + { + List schedules = entityPage.Values + .Where(metadata => + { + // If there's no filter, return all items + if (filter == null) + { + return true; + } + + // Check status filter if specified + bool statusMatches = !filter.Status.HasValue || metadata.State.Status == filter.Status.Value; + + // Check created from date filter if specified + bool createdFromMatches = !filter.CreatedFrom.HasValue || metadata.State.ScheduleCreatedAt > filter.CreatedFrom.Value; + + // Check created to date filter if specified + bool createdToMatches = !filter.CreatedTo.HasValue || metadata.State.ScheduleCreatedAt < filter.CreatedTo.Value; + + return statusMatches && createdFromMatches && createdToMatches; + }) + .Select(metadata => + { + ScheduleState state = metadata.State; + ScheduleConfiguration config = state.ScheduleConfiguration!; + return new ScheduleDescription + { + ScheduleId = metadata.Id.Key, + OrchestrationName = config.OrchestrationName, + OrchestrationInput = config.OrchestrationInput, + OrchestrationInstanceId = config.OrchestrationInstanceId, + StartAt = config.StartAt, + EndAt = config.EndAt, + Interval = config.Interval, + StartImmediatelyIfLate = config.StartImmediatelyIfLate, + Status = state.Status, + ExecutionToken = state.ExecutionToken, + LastRunAt = state.LastRunAt, + NextRunAt = state.NextRunAt, + }; + }) + .ToList(); + + return new Page(schedules, entityPage.ContinuationToken); + } + + // Return empty page if no results + return new Page(new List(), null); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // the operation was cancelled as requested. No need to log this. + throw; + } + catch (Exception ex) + { + this.logger.ClientError(nameof(this.ListSchedulesAsync), string.Empty, ex); + + throw; + } + }); + } +} diff --git a/src/ScheduledTasks/Client/ScheduleClient.cs b/src/ScheduledTasks/Client/ScheduleClient.cs new file mode 100644 index 00000000..cdab022e --- /dev/null +++ b/src/ScheduledTasks/Client/ScheduleClient.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Client for managing a specific schedule instance. +/// Provides methods to list, create, pause, resume, and manage schedules. +/// +public abstract class ScheduleClient +{ + /// + /// Initializes a new instance of the class. + /// + /// Id of schedule. + protected ScheduleClient(string scheduleId) + { + this.ScheduleId = Check.NotNullOrEmpty(scheduleId, nameof(scheduleId)); + } + + /// + /// Gets the ID of this schedule. + /// + public string ScheduleId { get; } + + /// + /// Creates or update a schedule with the specified configuration. + /// + /// The options for creating the schedule. + /// The cancellation token that can be used to cancel the operation. + /// A task that completes when the schedule has been created. + public abstract Task CreateAsync(ScheduleCreationOptions creationOptions, CancellationToken cancellation = default); + + /// + /// Retrieves the current details of this schedule. + /// + /// The cancellation token that can be used to cancel the operation. + /// A task that returns the schedule details when complete. + public abstract Task DescribeAsync(CancellationToken cancellation = default); + + /// + /// Deletes this schedule. + /// + /// + /// The schedule will stop executing and be removed from the system. + /// Does not affect orchestrations that have already been started. + /// + /// The cancellation token that can be used to cancel the operation. + /// A task that completes when the schedule has been deleted. + public abstract Task DeleteAsync(CancellationToken cancellation = default); + + /// + /// Pauses this schedule. + /// + /// + /// The schedule will stop executing but remain in the system. + /// Does not affect orchestrations that have already been started. + /// + /// The cancellation token that can be used to cancel the operation. + /// A task that completes when the schedule has been paused. + public abstract Task PauseAsync(CancellationToken cancellation = default); + + /// + /// Resumes this schedule. + /// + /// + /// The schedule will continue executing from where it was paused. + /// + /// The cancellation token that can be used to cancel the operation. + /// A task that completes when the schedule has been resumed. + public abstract Task ResumeAsync(CancellationToken cancellation = default); + + /// + /// Updates this schedule with new configuration. + /// + /// + /// The schedule will continue executing with the new configuration. + /// + /// The options for updating the schedule configuration. + /// The cancellation token that can be used to cancel the operation. + /// A task that completes when the schedule has been updated. + public abstract Task UpdateAsync(ScheduleUpdateOptions updateOptions, CancellationToken cancellation = default); +} diff --git a/src/ScheduledTasks/Client/ScheduledTaskClient.cs b/src/ScheduledTasks/Client/ScheduledTaskClient.cs new file mode 100644 index 00000000..edc7658a --- /dev/null +++ b/src/ScheduledTasks/Client/ScheduledTaskClient.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Client for managing scheduled tasks. +/// Provides methods to retrieve a ScheduleClient, list all schedules, +/// and get details of a specific schedule. +/// +public abstract class ScheduledTaskClient +{ + /// + /// Gets a handle to a schedule, allowing operations on it. + /// + /// The ID of the schedule. + /// A handle to manage the schedule. + public abstract ScheduleClient GetScheduleClient(string scheduleId); + + /// + /// Gets a schedule description by its ID. + /// + /// The ID of the schedule to retrieve. + /// Optional cancellation token. + /// The schedule description if found, null otherwise. + public abstract Task GetScheduleAsync(string scheduleId, CancellationToken cancellation = default); + + /// + /// Gets a pageable list of schedules matching the specified filter criteria. + /// + /// Optional filter criteria for the schedules. If null, returns all schedules. + /// A pageable list of schedule descriptions. + public abstract AsyncPageable ListSchedulesAsync(ScheduleQuery? filter = null); + + /// + /// Creates a new schedule with the specified configuration. + /// + /// The options for creating the schedule. + /// Optional cancellation token. + /// A ScheduleClient for the created schedule. + public abstract Task CreateScheduleAsync(ScheduleCreationOptions creationOptions, CancellationToken cancellation = default); +} diff --git a/src/ScheduledTasks/Entity/Schedule.cs b/src/ScheduledTasks/Entity/Schedule.cs new file mode 100644 index 00000000..516702e1 --- /dev/null +++ b/src/ScheduledTasks/Entity/Schedule.cs @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Entity that manages the state and execution of a scheduled task. +/// +/// +/// The Schedule entity maintains the configuration and state of a scheduled task, +/// handling operations like creation, updates, pausing/resuming, and executing the task +/// according to the defined schedule. +/// +/// Logger for recording schedule operations and events. +class Schedule(ILogger logger) : TaskEntity +{ + readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + /// + /// Creates a new schedule with the specified configuration. If already exists, update it in place. + /// + /// The task entity context. + /// The configuration options for creating the schedule. + /// Thrown when scheduleConfigurationCreateOptions is null. + /// Thrown when the schedule is already created. + public void CreateSchedule(TaskEntityContext context, ScheduleCreationOptions scheduleCreationOptions) + { + try + { + if (!this.CanTransitionTo(nameof(this.CreateSchedule), ScheduleStatus.Active)) + { + throw new ScheduleInvalidTransitionException(scheduleCreationOptions?.ScheduleId ?? string.Empty, this.State.Status, ScheduleStatus.Active, nameof(this.CreateSchedule)); + } + + if (scheduleCreationOptions == null) + { + throw new ScheduleClientValidationException(string.Empty, "Schedule creation options cannot be null"); + } + + bool alreadyExists = this.State.ScheduleCreatedAt != null; + + this.State.ScheduleConfiguration = ScheduleConfiguration.FromCreateOptions(scheduleCreationOptions); + + if (alreadyExists) + { + this.State.ScheduleLastModifiedAt = DateTimeOffset.UtcNow; + this.State.RefreshScheduleRunExecutionToken(); + this.State.NextRunAt = null; + } + else + { + this.State.Status = ScheduleStatus.Active; + this.State.ScheduleCreatedAt = this.State.ScheduleLastModifiedAt = DateTimeOffset.UtcNow; + } + + this.logger.CreatedSchedule(this.State.ScheduleConfiguration.ScheduleId); + + // Signal to run schedule immediately after creation and let runSchedule determine if it should run immediately + // or later to separate response from schedule creation and schedule responsibilities + context.SignalEntity(new EntityInstanceId(nameof(Schedule), this.State.ScheduleConfiguration.ScheduleId), nameof(this.RunSchedule), this.State.ExecutionToken); + } + catch (Exception ex) + { + this.logger.ScheduleOperationError(scheduleCreationOptions.ScheduleId, nameof(this.CreateSchedule), "Failed to create schedule", ex); + throw; + } + } + + /// + /// Updates an existing schedule. + /// + /// The task entity context. + /// The options for updating the schedule configuration. + /// Thrown when scheduleConfigUpdateOptions is null. + /// Thrown when the schedule is not created. + public void UpdateSchedule(TaskEntityContext context, ScheduleUpdateOptions scheduleUpdateOptions) + { + try + { + if (!this.CanTransitionTo(nameof(this.UpdateSchedule), this.State.Status)) + { + throw new ScheduleInvalidTransitionException(this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, this.State.Status, this.State.Status, nameof(this.UpdateSchedule)); + } + + if (scheduleUpdateOptions == null) + { + throw new ScheduleClientValidationException(this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, "Schedule update options cannot be null"); + } + + Verify.NotNull(this.State.ScheduleConfiguration, nameof(this.State.ScheduleConfiguration)); + + HashSet updatedScheduleConfigFields = this.State.ScheduleConfiguration.Update(scheduleUpdateOptions); + if (updatedScheduleConfigFields.Count == 0) + { + // no need to interrupt and update current schedule run as there is no change in the schedule config + this.logger.ScheduleOperationDebug(this.State.ScheduleConfiguration.ScheduleId, nameof(this.UpdateSchedule), "Schedule configuration is up to date."); + return; + } + + this.State.ScheduleLastModifiedAt = DateTimeOffset.UtcNow; + + // after schedule config is updated, perform post-config-update logic separately + foreach (string updatedScheduleConfigField in updatedScheduleConfigFields) + { + switch (updatedScheduleConfigField) + { + case nameof(this.State.ScheduleConfiguration.StartAt): + case nameof(this.State.ScheduleConfiguration.Interval): + case nameof(this.State.ScheduleConfiguration.StartImmediatelyIfLate): + this.State.NextRunAt = null; + break; + + // TODO: add other fields's callback logic after config update if any + default: + break; + } + } + + this.State.RefreshScheduleRunExecutionToken(); + + this.logger.UpdatedSchedule(this.State.ScheduleConfiguration.ScheduleId); + + if (this.State.Status == ScheduleStatus.Active) + { + context.SignalEntity( + new EntityInstanceId(nameof(Schedule), this.State.ScheduleConfiguration.ScheduleId), + nameof(this.RunSchedule), + this.State.ExecutionToken); + } + } + catch (Exception ex) + { + this.logger.ScheduleOperationError(this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, nameof(this.UpdateSchedule), "Failed to update schedule", ex); + throw; + } + } + + /// + /// Pauses the schedule. + /// + /// The task entity context. + public void PauseSchedule(TaskEntityContext context) + { + try + { + if (!this.CanTransitionTo(nameof(this.PauseSchedule), ScheduleStatus.Paused)) + { + throw new ScheduleInvalidTransitionException(this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, this.State.Status, ScheduleStatus.Paused, nameof(this.PauseSchedule)); + } + + Verify.NotNull(this.State.ScheduleConfiguration, nameof(this.State.ScheduleConfiguration)); + + // Transition to Paused state + this.State.Status = ScheduleStatus.Paused; + this.State.NextRunAt = null; + this.State.RefreshScheduleRunExecutionToken(); + + this.logger.PausedSchedule(this.State.ScheduleConfiguration.ScheduleId); + } + catch (Exception ex) + { + this.logger.ScheduleOperationError(this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, nameof(this.PauseSchedule), "Failed to pause schedule", ex); + throw; + } + } + + /// + /// Resumes the schedule. + /// + /// The task entity context. + /// Thrown when the schedule is not paused. + public void ResumeSchedule(TaskEntityContext context) + { + try + { + if (!this.CanTransitionTo(nameof(this.ResumeSchedule), ScheduleStatus.Active)) + { + throw new ScheduleInvalidTransitionException(this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, this.State.Status, ScheduleStatus.Active, nameof(this.ResumeSchedule)); + } + + Verify.NotNull(this.State.ScheduleConfiguration, nameof(this.State.ScheduleConfiguration)); + + this.State.Status = ScheduleStatus.Active; + this.State.NextRunAt = null; + this.logger.ResumedSchedule(this.State.ScheduleConfiguration.ScheduleId); + + // compute next run based on startat and interval + context.SignalEntity(new EntityInstanceId(nameof(Schedule), this.State.ScheduleConfiguration.ScheduleId), nameof(this.RunSchedule), this.State.ExecutionToken); + } + catch (Exception ex) + { + this.logger.ScheduleOperationError(this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, nameof(this.ResumeSchedule), "Failed to resume schedule", ex); + throw; + } + } + + /// + /// Runs the schedule based on the defined configuration. + /// + /// The task entity context. + /// The execution token for the schedule. + /// Thrown when the schedule is not active or interval is not specified. + public void RunSchedule(TaskEntityContext context, string executionToken) + { + if (this.State.Status == ScheduleStatus.Uninitialized) + { + // this signal is no longer useful since the schedule has been deleted. + this.State = null!; // delete again, otherwise an uninitialized schedule will stick around + return; + } + + ScheduleConfiguration scheduleConfig = + this.State.ScheduleConfiguration ?? + throw new InvalidOperationException("Schedule configuration is missing."); + TimeSpan interval = scheduleConfig.Interval; + + if (executionToken != this.State.ExecutionToken) + { + this.logger.ScheduleRunCancelled(scheduleConfig.ScheduleId, executionToken); + return; + } + + if (this.State.Status != ScheduleStatus.Active) + { + string errorMessage = "Schedule must be in Active status to run."; + Exception exception = new InvalidOperationException(errorMessage); + this.logger.ScheduleOperationError(scheduleConfig.ScheduleId, nameof(this.RunSchedule), errorMessage); + throw exception; + } + + // if endat is set and time now is past endat, do not run + if (scheduleConfig.EndAt.HasValue && DateTimeOffset.UtcNow > scheduleConfig.EndAt.Value) + { + this.logger.ScheduleRunCancelled(scheduleConfig.ScheduleId, executionToken); + this.State.NextRunAt = null; + + context.SignalEntity( + new EntityInstanceId(nameof(Schedule), scheduleConfig.ScheduleId), + "delete", + this.State.ExecutionToken); + + return; + } + + this.State.NextRunAt = this.DetermineNextRunTime(scheduleConfig); + + DateTimeOffset currentTime = DateTimeOffset.UtcNow; + + if (this.State.NextRunAt!.Value <= currentTime) + { + this.StartOrchestration(context, this.State.NextRunAt!.Value); + this.State.LastRunAt = this.State.NextRunAt!.Value; + this.State.NextRunAt = null; + this.State.NextRunAt = this.DetermineNextRunTime(scheduleConfig); + } + + context.SignalEntity( + new EntityInstanceId( + nameof(Schedule), + this.State.ScheduleConfiguration.ScheduleId), + nameof(this.RunSchedule), + this.State.ExecutionToken, + new SignalEntityOptions { SignalTime = this.State.NextRunAt.Value }); + } + + void StartOrchestration(TaskEntityContext context, DateTimeOffset scheduledRunTime) + { + try + { + string? instanceId = this.State.ScheduleConfiguration?.OrchestrationInstanceId; + StartOrchestrationOptions startOrchestrationOptions; + + if (string.IsNullOrEmpty(instanceId)) + { + // Generate unique instance ID based on schedule name and current time + instanceId = $"{this.State.ScheduleConfiguration!.ScheduleId}-{scheduledRunTime:o}"; + startOrchestrationOptions = new StartOrchestrationOptions(instanceId); + } + else + { + // Use configured instance ID which will prevent concurrent runs + startOrchestrationOptions = new StartOrchestrationOptions(instanceId); + } + + this.logger.ScheduleOperationInfo( + this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, + nameof(this.StartOrchestration), + $"Starting new orchestration named '{this.State.ScheduleConfiguration?.OrchestrationName ?? string.Empty}' with instance ID: {instanceId}"); + + context.ScheduleNewOrchestration( + new TaskName(this.State.ScheduleConfiguration!.OrchestrationName), + this.State.ScheduleConfiguration.OrchestrationInput, + startOrchestrationOptions); + } + catch (Exception ex) + { + this.logger.ScheduleOperationError( + this.State.ScheduleConfiguration?.ScheduleId ?? string.Empty, + nameof(this.StartOrchestration), + "Failed to start orchestration", + ex); + } + } + + bool CanTransitionTo(string operationName, ScheduleStatus targetStatus) + { + return ScheduleTransitions.IsValidTransition(operationName, this.State.Status, targetStatus); + } + + DateTimeOffset DetermineNextRunTime(ScheduleConfiguration scheduleConfig) + { + if (this.State.NextRunAt.HasValue) + { + return this.State.NextRunAt.Value; // NextRunAt already set, no need to compute + } + + // timenow + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset startTime = scheduleConfig.StartAt ?? this.State.ScheduleCreatedAt ?? now; + + // compute time gap between now and startat if set else with ScheduleCreatedAt + TimeSpan timeSinceStart = now - startTime; + + // timeSinceStart is negative that means next run time should be in future + if (timeSinceStart < TimeSpan.Zero) + { + return startTime; + } + + // timeSinceStart is >= 0, this mean current time already past start time + bool isFirstRun = this.State.LastRunAt == null; + + // check edge case: if this is first run and startimmediatelyiflate is true, run immediately + if (isFirstRun && scheduleConfig.StartImmediatelyIfLate) + { + return now; + } + + // Calculate number of intervals between start time and now + int intervalsElapsed = (int)(timeSinceStart.Ticks / scheduleConfig.Interval.Ticks); + + // Compute next run time based on intervals elapsed since start + return startTime + TimeSpan.FromTicks(scheduleConfig.Interval.Ticks * (intervalsElapsed + 1)); + } +} diff --git a/src/ScheduledTasks/Exception/ScheduleClientValidationException.cs b/src/ScheduledTasks/Exception/ScheduleClientValidationException.cs new file mode 100644 index 00000000..98a1a663 --- /dev/null +++ b/src/ScheduledTasks/Exception/ScheduleClientValidationException.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Exception thrown when client-side validation fails for schedule operations. +/// +public class ScheduleClientValidationException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule that failed validation. + /// The validation error message. + public ScheduleClientValidationException(string scheduleId, string message) + : base($"Validation failed for schedule '{scheduleId}': {message}") + { + this.ScheduleId = scheduleId; + } + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule that failed validation. + /// The validation error message. + /// The exception that is the cause of the current exception. + public ScheduleClientValidationException(string scheduleId, string message, Exception innerException) + : base($"Validation failed for schedule '{scheduleId}': {message}", innerException) + { + this.ScheduleId = scheduleId; + } + + /// + /// Gets the ID of the schedule that failed validation. + /// + public string ScheduleId { get; } +} diff --git a/src/ScheduledTasks/Exception/ScheduleInvalidTransitionException.cs b/src/ScheduledTasks/Exception/ScheduleInvalidTransitionException.cs new file mode 100644 index 00000000..b95e3824 --- /dev/null +++ b/src/ScheduledTasks/Exception/ScheduleInvalidTransitionException.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Exception thrown when an invalid state transition is attempted on a schedule. +/// +public class ScheduleInvalidTransitionException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule on which the invalid transition was attempted. + /// The current status of the schedule. + /// The target status that was invalid. + /// The name of the operation that was attempted. + public ScheduleInvalidTransitionException(string scheduleId, ScheduleStatus fromStatus, ScheduleStatus toStatus, string operationName) + : base($"Invalid state transition attempted for schedule '{scheduleId}': Cannot transition from {fromStatus} to {toStatus} during {operationName} operation.") + { + this.ScheduleId = scheduleId; + this.FromStatus = fromStatus; + this.ToStatus = toStatus; + this.OperationName = operationName; + } + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule on which the invalid transition was attempted. + /// The current status of the schedule. + /// The target status that was invalid. + /// The name of the operation that was attempted. + /// The exception that is the cause of the current exception. + public ScheduleInvalidTransitionException(string scheduleId, ScheduleStatus fromStatus, ScheduleStatus toStatus, string operationName, Exception innerException) + : base($"Invalid state transition attempted for schedule '{scheduleId}': Cannot transition from {fromStatus} to {toStatus} during {operationName} operation.", innerException) + { + this.ScheduleId = scheduleId; + this.FromStatus = fromStatus; + this.ToStatus = toStatus; + this.OperationName = operationName; + } + + /// + /// Gets the ID of the schedule that encountered the invalid transition. + /// + public string ScheduleId { get; } + + /// + /// Gets the status the schedule was transitioning from. + /// + public ScheduleStatus FromStatus { get; } + + /// + /// Gets the invalid target status that was attempted. + /// + public ScheduleStatus ToStatus { get; } + + /// + /// Gets the name of the operation that was attempted. + /// + public string OperationName { get; } +} diff --git a/src/ScheduledTasks/Exception/ScheduleNotFoundException.cs b/src/ScheduledTasks/Exception/ScheduleNotFoundException.cs new file mode 100644 index 00000000..e884b6b8 --- /dev/null +++ b/src/ScheduledTasks/Exception/ScheduleNotFoundException.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Exception thrown when attempting to access a schedule that does not exist. +/// +public class ScheduleNotFoundException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule that was not found. + public ScheduleNotFoundException(string scheduleId) + : base($"Schedule with ID '{scheduleId}' was not found.") + { + this.ScheduleId = scheduleId; + } + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule that was not found. + /// The exception that is the cause of the current exception. + public ScheduleNotFoundException(string scheduleId, Exception innerException) + : base($"Schedule with ID '{scheduleId}' was not found.", innerException) + { + this.ScheduleId = scheduleId; + } + + /// + /// Gets the ID of the schedule that was not found. + /// + public string ScheduleId { get; } +} diff --git a/src/ScheduledTasks/Extension/DurableTaskClientBuilderExtensions.cs b/src/ScheduledTasks/Extension/DurableTaskClientBuilderExtensions.cs new file mode 100644 index 00000000..159ec31a --- /dev/null +++ b/src/ScheduledTasks/Extension/DurableTaskClientBuilderExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Extension methods for configuring Durable Task clients to use scheduled tasks. +/// +public static class DurableTaskClientBuilderExtensions +{ + /// + /// Enables scheduled tasks support for the client builder. + /// + /// The client builder to add scheduled task support to. + /// The original builder, for call chaining. + public static IDurableTaskClientBuilder UseScheduledTasks(this IDurableTaskClientBuilder builder) + { + builder.Services.AddSingleton(); + return builder; + } +} diff --git a/src/ScheduledTasks/Extension/DurableTaskWorkerBuilderExtensions.cs b/src/ScheduledTasks/Extension/DurableTaskWorkerBuilderExtensions.cs new file mode 100644 index 00000000..0dbdafb6 --- /dev/null +++ b/src/ScheduledTasks/Extension/DurableTaskWorkerBuilderExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Extension methods for configuring Durable Task workers to use the Azure Durable Task Scheduler service. +/// +public static class DurableTaskWorkerBuilderExtensions +{ + /// + /// Adds scheduled tasks support to the worker builder. + /// + /// The worker builder to add scheduled task support to. + public static void UseScheduledTasks(this IDurableTaskWorkerBuilder builder) + { + builder.AddTasks(r => + { + r.AddEntity(nameof(Schedule), sp => ActivatorUtilities.CreateInstance(sp)); + r.AddOrchestrator(); + }); + } +} diff --git a/src/ScheduledTasks/Logging/Logs.Client.cs b/src/ScheduledTasks/Logging/Logs.Client.cs new file mode 100644 index 00000000..440e13db --- /dev/null +++ b/src/ScheduledTasks/Logging/Logs.Client.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Log messages. +/// +static partial class Logs +{ + [LoggerMessage(EventId = 80, Level = LogLevel.Information, Message = "Creating schedule with options: {scheduleConfigCreateOptions}")] + public static partial void ClientCreatingSchedule(this ILogger logger, ScheduleCreationOptions scheduleConfigCreateOptions); + + [LoggerMessage(EventId = 81, Level = LogLevel.Information, Message = "Pausing schedule '{scheduleId}'")] + public static partial void ClientPausingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 82, Level = LogLevel.Information, Message = "Resuming schedule '{scheduleId}'")] + public static partial void ClientResumingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 83, Level = LogLevel.Information, Message = "Updating schedule '{scheduleId}'")] + public static partial void ClientUpdatingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 84, Level = LogLevel.Information, Message = "Deleting schedule '{scheduleId}'")] + public static partial void ClientDeletingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 85, Level = LogLevel.Information, Message = "{message} (ScheduleId: {scheduleId})")] + public static partial void ClientInfo(this ILogger logger, string message, string scheduleId); + + [LoggerMessage(EventId = 86, Level = LogLevel.Warning, Message = "{message} (ScheduleId: {scheduleId})")] + public static partial void ClientWarning(this ILogger logger, string message, string scheduleId); + + [LoggerMessage(EventId = 87, Level = LogLevel.Error, Message = "{message} (ScheduleId: {scheduleId})")] + public static partial void ClientError(this ILogger logger, string message, string scheduleId, Exception? exception = null); +} diff --git a/src/ScheduledTasks/Logging/Logs.Entity.cs b/src/ScheduledTasks/Logging/Logs.Entity.cs new file mode 100644 index 00000000..fba047e7 --- /dev/null +++ b/src/ScheduledTasks/Logging/Logs.Entity.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Log messages. +/// +static partial class Logs +{ + [LoggerMessage(EventId = 100, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is being created")] + public static partial void CreatingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 101, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is created")] + public static partial void CreatedSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 102, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is being updated")] + public static partial void UpdatingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 103, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is updated")] + public static partial void UpdatedSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 104, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is being paused")] + public static partial void PausingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 105, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is paused")] + public static partial void PausedSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 106, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is being resumed")] + public static partial void ResumingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 107, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is resumed")] + public static partial void ResumedSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 108, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is running")] + public static partial void RunningSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 109, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is executed")] + public static partial void CompletedScheduleRun(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 110, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is being deleted")] + public static partial void DeletingSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 111, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' is deleted")] + public static partial void DeletedSchedule(this ILogger logger, string scheduleId); + + [LoggerMessage(EventId = 112, Level = LogLevel.Debug, Message = "Schedule '{scheduleId}' operation '{operationName}' debug: {debugMessage}")] + public static partial void ScheduleOperationDebug(this ILogger logger, string scheduleId, string operationName, string debugMessage); + + [LoggerMessage(EventId = 113, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' operation '{operationName}' info: {infoMessage}")] + public static partial void ScheduleOperationInfo(this ILogger logger, string scheduleId, string operationName, string infoMessage); + + [LoggerMessage(EventId = 114, Level = LogLevel.Warning, Message = "Schedule '{scheduleId}' operation '{operationName}' warning: {warningMessage}")] + public static partial void ScheduleOperationWarning(this ILogger logger, string scheduleId, string operationName, string warningMessage); + + [LoggerMessage(EventId = 115, Level = LogLevel.Error, Message = "Operation '{operationName}' failed for schedule '{scheduleId}': {errorMessage}")] + public static partial void ScheduleOperationError(this ILogger logger, string scheduleId, string operationName, string errorMessage, Exception? exception = null); + + [LoggerMessage(EventId = 116, Level = LogLevel.Information, Message = "Schedule '{scheduleId}' run cancelled with execution token '{executionToken}'")] + public static partial void ScheduleRunCancelled(this ILogger logger, string scheduleId, string executionToken); +} diff --git a/src/ScheduledTasks/Models/ScheduleConfiguration.cs b/src/ScheduledTasks/Models/ScheduleConfiguration.cs new file mode 100644 index 00000000..8f3d1c3c --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleConfiguration.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Configuration for a scheduled task. +/// +class ScheduleConfiguration +{ + string orchestrationName; + TimeSpan interval; + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule. + /// The name of the orchestration to schedule. + /// The interval between schedule executions. Must be positive and at least 1 second. + public ScheduleConfiguration(string scheduleId, string orchestrationName, TimeSpan interval) + { + this.ScheduleId = Check.NotNullOrEmpty(scheduleId, nameof(scheduleId)); + this.orchestrationName = Check.NotNullOrEmpty(orchestrationName, nameof(orchestrationName)); + if (interval <= TimeSpan.Zero) + { + throw new ArgumentException("Interval must be positive", nameof(interval)); + } + + if (interval.TotalSeconds < 1) + { + throw new ArgumentException("Interval must be at least 1 second", nameof(interval)); + } + + this.Interval = interval; + } + + /// + /// Gets or Sets the name of the orchestration function to schedule. + /// + public string OrchestrationName + { + get => this.orchestrationName; + set => this.orchestrationName = Check.NotNullOrEmpty(value, nameof(this.OrchestrationName)); + } + + /// + /// Gets the ID of the schedule. + /// + public string ScheduleId { get; } + + /// + /// Gets or sets the input to the orchestration function. + /// + public string? OrchestrationInput { get; set; } + + /// + /// Gets or sets the instance ID of the orchestration function. + /// + public string? OrchestrationInstanceId { get; set; } + + /// + /// Gets or sets the start time of the schedule. + /// + public DateTimeOffset? StartAt { get; set; } + + /// + /// Gets or sets the end time of the schedule. + /// + public DateTimeOffset? EndAt { get; set; } + + /// + /// Gets or sets the interval between schedule executions. + /// + public TimeSpan Interval + { + get => this.interval; + set + { + if (value <= TimeSpan.Zero) + { + throw new ArgumentException("Interval must be positive", nameof(value)); + } + + if (value.TotalSeconds < 1) + { + throw new ArgumentException("Interval must be at least 1 second", nameof(value)); + } + + this.interval = value; + } + } + + /// + /// Gets or sets a value indicating whether to start the orchestration immediately when the current time is past the StartAt time. + /// By default it is false. + /// If false, the first run will be scheduled at the next interval based on the original start time. + /// If true, the first run will start immediately and subsequent runs will follow the regular interval. + /// + public bool StartImmediatelyIfLate { get; set; } + + /// + /// Creates a new configuration from the provided creation options. + /// + /// The options to create the configuration from. + /// A new schedule configuration. + public static ScheduleConfiguration FromCreateOptions(ScheduleCreationOptions createOptions) + { + Check.NotNull(createOptions, nameof(createOptions)); + + ScheduleConfiguration scheduleConfig = new ScheduleConfiguration(createOptions.ScheduleId, createOptions.OrchestrationName, createOptions.Interval) + { + OrchestrationInput = createOptions.OrchestrationInput, + OrchestrationInstanceId = createOptions.OrchestrationInstanceId, + StartAt = createOptions.StartAt, + EndAt = createOptions.EndAt, + StartImmediatelyIfLate = createOptions.StartImmediatelyIfLate, + }; + + scheduleConfig.Validate(); + + return scheduleConfig; + } + + /// + /// Updates this configuration with the provided update options. + /// + /// The options to update the configuration with. + /// A set of field names that were updated. + public HashSet Update(ScheduleUpdateOptions updateOptions) + { + Check.NotNull(updateOptions, nameof(updateOptions)); + HashSet updatedFields = new HashSet(); + + if (!string.IsNullOrEmpty(updateOptions.OrchestrationName) + && updateOptions.OrchestrationName != this.OrchestrationName) + { + this.OrchestrationName = updateOptions.OrchestrationName; + updatedFields.Add(nameof(this.OrchestrationName)); + } + + if (!string.IsNullOrEmpty(updateOptions.OrchestrationInput) + && updateOptions.OrchestrationInput != this.OrchestrationInput) + { + this.OrchestrationInput = updateOptions.OrchestrationInput; + updatedFields.Add(nameof(this.OrchestrationInput)); + } + + if (!string.IsNullOrEmpty(updateOptions.OrchestrationInstanceId) + && updateOptions.OrchestrationInstanceId != this.OrchestrationInstanceId) + { + this.OrchestrationInstanceId = updateOptions.OrchestrationInstanceId; + updatedFields.Add(nameof(this.OrchestrationInstanceId)); + } + + if (updateOptions.StartAt.HasValue + && updateOptions.StartAt != this.StartAt) + { + this.StartAt = updateOptions.StartAt; + updatedFields.Add(nameof(this.StartAt)); + } + + if (updateOptions.EndAt.HasValue + && updateOptions.EndAt != this.EndAt) + { + this.EndAt = updateOptions.EndAt; + updatedFields.Add(nameof(this.EndAt)); + } + + if (updateOptions.Interval.HasValue + && updateOptions.Interval != this.Interval) + { + this.Interval = updateOptions.Interval.Value; + updatedFields.Add(nameof(this.Interval)); + } + + if (updateOptions.StartImmediatelyIfLate.HasValue + && updateOptions.StartImmediatelyIfLate != this.StartImmediatelyIfLate) + { + this.StartImmediatelyIfLate = updateOptions.StartImmediatelyIfLate.Value; + updatedFields.Add(nameof(this.StartImmediatelyIfLate)); + } + + this.Validate(); + return updatedFields; + } + + void Validate() + { + if (this.StartAt.HasValue && this.EndAt.HasValue && this.StartAt.Value > this.EndAt.Value) + { + throw new ArgumentException("StartAt cannot be later than EndAt.", nameof(this.StartAt)); + } + } +} diff --git a/src/ScheduledTasks/Models/ScheduleCreationOptions.cs b/src/ScheduledTasks/Models/ScheduleCreationOptions.cs new file mode 100644 index 00000000..fda1fa1a --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleCreationOptions.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Configuration for a scheduled task. +/// +public record ScheduleCreationOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the schedule. + /// The name of the orchestration to schedule. + /// The time interval between schedule executions. Must be at least 1 second and cannot be negative. + public ScheduleCreationOptions(string scheduleId, string orchestrationName, TimeSpan interval) + { + this.ScheduleId = Check.NotNullOrEmpty(scheduleId, nameof(scheduleId)); + this.OrchestrationName = Check.NotNullOrEmpty(orchestrationName, nameof(orchestrationName)); + if (interval <= TimeSpan.Zero) + { + throw new ArgumentException("Interval must be positive", nameof(interval)); + } + + if (interval.TotalSeconds < 1) + { + throw new ArgumentException("Interval must be at least 1 second", nameof(interval)); + } + + this.Interval = interval; + } + + /// + /// Gets the name of the orchestration function to schedule. + /// + public string OrchestrationName { get; } + + /// + /// Gets the ID of the schedule. + /// + public string ScheduleId { get; } + + /// + /// Gets the input to the orchestration function. + /// + public string? OrchestrationInput { get; init; } + + /// + /// Gets the instance ID of the orchestration function. + /// + public string? OrchestrationInstanceId { get; init; } + + /// + /// Gets the start time of the schedule. If not provided, default to the current time. + /// + public DateTimeOffset? StartAt { get; init; } + + /// + /// Gets the end time of the schedule. If not provided, schedule will run indefinitely. + /// + public DateTimeOffset? EndAt { get; init; } + + /// + /// Gets the interval of the schedule. + /// + public TimeSpan Interval { get; } + + /// + /// Gets a value indicating whether to start the orchestration immediately when the current time is past the StartAt time. + /// By default it is false. + /// If false, the first run will be scheduled at the next interval based on the original start time. + /// If true, the first run will start immediately and subsequent runs will follow the regular interval. + /// + public bool StartImmediatelyIfLate { get; init; } +} diff --git a/src/ScheduledTasks/Models/ScheduleDescription.cs b/src/ScheduledTasks/Models/ScheduleDescription.cs new file mode 100644 index 00000000..d5e6691d --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleDescription.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Represents the comprehensive details of a schedule. +/// +public record ScheduleDescription +{ + /// + /// Gets the unique identifier for the schedule. + /// + public string ScheduleId { get; init; } = string.Empty; + + /// + /// Gets the name of the orchestration to run. + /// + public string? OrchestrationName { get; init; } + + /// + /// Gets the optional input for the orchestration. + /// + public string? OrchestrationInput { get; init; } + + /// + /// Gets the optional instance ID for the orchestration. + /// + public string? OrchestrationInstanceId { get; init; } + + /// + /// Gets the optional start time for the schedule. + /// + public DateTimeOffset? StartAt { get; init; } + + /// + /// Gets the optional end time for the schedule. + /// + public DateTimeOffset? EndAt { get; init; } + + /// + /// Gets the optional interval between schedule runs. + /// + public TimeSpan? Interval { get; init; } + + /// + /// Gets a value indicating whether to start the orchestration immediately when the current time is past the StartAt time. + /// By default it is false. + /// If false, the first run will be scheduled at the next interval based on the original start time. + /// If true, the first run will start immediately and subsequent runs will follow the regular interval. + /// + public bool? StartImmediatelyIfLate { get; init; } + + /// + /// Gets the current status of the schedule. + /// + public ScheduleStatus Status { get; init; } + + /// + /// Gets the execution token used to validate schedule operations. + /// + public string ExecutionToken { get; init; } = string.Empty; + + /// + /// Gets the last time the schedule was run. + /// + public DateTimeOffset? LastRunAt { get; init; } + + /// + /// Gets the next scheduled run time. + /// + public DateTimeOffset? NextRunAt { get; init; } +} diff --git a/src/ScheduledTasks/Models/ScheduleQuery.cs b/src/ScheduledTasks/Models/ScheduleQuery.cs new file mode 100644 index 00000000..17d07d42 --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleQuery.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Represents query parameters for filtering schedules. +/// +public record ScheduleQuery +{ + /// + /// The default page size when not supplied. + /// + public const int DefaultPageSize = 100; + + /// + /// Gets the filter for the schedule status. + /// + public ScheduleStatus? Status { get; init; } + + /// + /// Gets the prefix to filter schedule IDs. + /// + public string? ScheduleIdPrefix { get; init; } + + /// + /// Gets the filter for schedules created after this time. + /// + public DateTimeOffset? CreatedFrom { get; init; } + + /// + /// Gets the filter for schedules created before this time. + /// + public DateTimeOffset? CreatedTo { get; init; } + + /// + /// Gets the maximum number of schedules to return per page. + /// + public int? PageSize { get; init; } + + /// + /// Gets the continuation token for retrieving the next page of results. + /// + public string? ContinuationToken { get; init; } +} diff --git a/src/ScheduledTasks/Models/ScheduleState.cs b/src/ScheduledTasks/Models/ScheduleState.cs new file mode 100644 index 00000000..48783de4 --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleState.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Represents the current state of a schedule. +/// +class ScheduleState +{ + /// + /// Gets or sets the current status of the schedule. + /// + public ScheduleStatus Status { get; set; } = ScheduleStatus.Uninitialized; + + /// + /// Gets or sets the execution token used to validate schedule operations. + /// + public string ExecutionToken { get; set; } = Guid.NewGuid().ToString("N"); + + /// + /// Gets or sets the last time the schedule was run. + /// + public DateTimeOffset? LastRunAt { get; set; } + + /// + /// Gets or sets the next scheduled run time. + /// + public DateTimeOffset? NextRunAt { get; set; } + + /// + /// Gets or sets the time when this schedule was created. + /// + public DateTimeOffset? ScheduleCreatedAt { get; set; } + + /// + /// Gets or sets the time when this schedule was last modified. + /// + public DateTimeOffset? ScheduleLastModifiedAt { get; set; } + + /// + /// Gets or sets the schedule configuration. + /// + public ScheduleConfiguration? ScheduleConfiguration { get; set; } + + /// + /// Refreshes the execution token to invalidate pending schedule operations. + /// + public void RefreshScheduleRunExecutionToken() + { + this.ExecutionToken = Guid.NewGuid().ToString("N"); + } +} \ No newline at end of file diff --git a/src/ScheduledTasks/Models/ScheduleStatus.cs b/src/ScheduledTasks/Models/ScheduleStatus.cs new file mode 100644 index 00000000..94e0c191 --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleStatus.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Represents the current status of a schedule. +/// +public enum ScheduleStatus +{ + /// + /// Schedule has not been created. + /// + Uninitialized, + + /// + /// Schedule is active and running. + /// + Active, + + /// + /// Schedule is paused. + /// + Paused, +} diff --git a/src/ScheduledTasks/Models/ScheduleTransitions.cs b/src/ScheduledTasks/Models/ScheduleTransitions.cs new file mode 100644 index 00000000..d7cf7939 --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleTransitions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Manages valid state transitions for schedules. +/// +static class ScheduleTransitions +{ + /// + /// Checks if a transition to the target state is valid for a given schedule state and operation. + /// + /// The name of the operation being performed. + /// The current schedule state. + /// The target state to transition to. + /// True if the transition is valid; otherwise, false. + public static bool IsValidTransition(string operationName, ScheduleStatus from, ScheduleStatus targetState) + { + return operationName switch + { + nameof(Schedule.CreateSchedule) => from switch + { + ScheduleStatus.Uninitialized when targetState == ScheduleStatus.Active => true, + ScheduleStatus.Active when targetState == ScheduleStatus.Active => true, + ScheduleStatus.Paused when targetState == ScheduleStatus.Active => true, + _ => false, + }, + nameof(Schedule.UpdateSchedule) => from switch + { + ScheduleStatus.Active when targetState == ScheduleStatus.Active => true, + ScheduleStatus.Paused when targetState == ScheduleStatus.Paused => true, + _ => false, + }, + nameof(Schedule.PauseSchedule) => from switch + { + ScheduleStatus.Active when targetState == ScheduleStatus.Paused => true, + _ => false, + }, + nameof(Schedule.ResumeSchedule) => from switch + { + ScheduleStatus.Paused when targetState == ScheduleStatus.Active => true, + _ => false, + }, + _ => false, + }; + } +} diff --git a/src/ScheduledTasks/Models/ScheduleUpdateOptions.cs b/src/ScheduledTasks/Models/ScheduleUpdateOptions.cs new file mode 100644 index 00000000..e9185c61 --- /dev/null +++ b/src/ScheduledTasks/Models/ScheduleUpdateOptions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.ScheduledTasks; + +/// +/// Options for updating an existing schedule. +/// +public record ScheduleUpdateOptions +{ + TimeSpan? interval; + + /// + /// Gets or initializes the name of the orchestration function to schedule. + /// + public string? OrchestrationName { get; init; } + + /// + /// Gets or initializes the input to the orchestration function. + /// + public string? OrchestrationInput { get; init; } + + /// + /// Gets or initializes the instance ID of the orchestration function. + /// + public string? OrchestrationInstanceId { get; init; } + + /// + /// Gets or initializes the start time of the schedule. + /// + public DateTimeOffset? StartAt { get; init; } + + /// + /// Gets or initializes the end time of the schedule. + /// + public DateTimeOffset? EndAt { get; init; } + + /// + /// Gets or initializes the interval of the schedule. + /// + public TimeSpan? Interval + { + get => this.interval; + init + { + if (value.HasValue) + { + if (value.Value <= TimeSpan.Zero) + { + throw new ArgumentException("Interval must be positive", nameof(value)); + } + + if (value.Value.TotalSeconds < 1) + { + throw new ArgumentException("Interval must be at least 1 second", nameof(value)); + } + } + + this.interval = value; + } + } + + /// + /// Gets a value indicating whether to start the orchestration immediately when the current time is past the StartAt time. + /// By default it is false. + /// If false, the first run will be scheduled at the next interval based on the original start time. + /// If true, the first run will start immediately and subsequent runs will follow the regular interval. + /// + public bool? StartImmediatelyIfLate { get; init; } +} diff --git a/src/ScheduledTasks/Orchestrations/ExecuteScheduleOperationOrchestrator.cs b/src/ScheduledTasks/Orchestrations/ExecuteScheduleOperationOrchestrator.cs new file mode 100644 index 00000000..0f2baa0b --- /dev/null +++ b/src/ScheduledTasks/Orchestrations/ExecuteScheduleOperationOrchestrator.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Entities; + +namespace Microsoft.DurableTask.ScheduledTasks; + +// TODO: logging +// TODO: May need separate orchs, result is obj now + +/// +/// Orchestrator that executes operations on schedule entities. +/// Calls the specified operation on the target entity and returns the result. +/// +[DurableTask] +public class ExecuteScheduleOperationOrchestrator : TaskOrchestrator +{ + /// + public override async Task RunAsync(TaskOrchestrationContext context, ScheduleOperationRequest input) + { + return await context.Entities.CallEntityAsync(input.EntityId, input.OperationName, input.Input); + } +} + +/// +/// Request for executing a schedule operation. +/// +/// The ID of the entity to execute the operation on. +/// The name of the operation to execute. +/// Optional input for the operation. +public record ScheduleOperationRequest(EntityInstanceId EntityId, string OperationName, object? Input = null); diff --git a/src/ScheduledTasks/ScheduledTasks.csproj b/src/ScheduledTasks/ScheduledTasks.csproj new file mode 100644 index 00000000..a9951609 --- /dev/null +++ b/src/ScheduledTasks/ScheduledTasks.csproj @@ -0,0 +1,20 @@ + + + + net6.0 + Durable Task Scheduled Tasks Client + true + preview.1 + + + + + + + + + + + + + diff --git a/test/ScheduledTasks.Tests/Client/DefaultScheduleClientTests.cs b/test/ScheduledTasks.Tests/Client/DefaultScheduleClientTests.cs new file mode 100644 index 00000000..70cc60fc --- /dev/null +++ b/test/ScheduledTasks.Tests/Client/DefaultScheduleClientTests.cs @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.DurableTask.ScheduledTasks.Tests.Client; + +public class DefaultScheduleClientTests +{ + readonly Mock durableTaskClient; + readonly Mock entityClient; + readonly ILogger logger; + readonly DefaultScheduleClient client; + readonly string scheduleId = "test-schedule"; + + public DefaultScheduleClientTests() + { + this.durableTaskClient = new Mock("test"); + this.entityClient = new Mock("test"); + this.logger = new TestLogger(); + this.durableTaskClient.Setup(x => x.Entities).Returns(this.entityClient.Object); + this.client = new DefaultScheduleClient(this.durableTaskClient.Object, this.scheduleId, this.logger); + } + + [Fact] + public void Constructor_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => + new DefaultScheduleClient(null!, this.scheduleId, this.logger)); + Assert.Equal("client", ex.ParamName); + } + + [Theory] + [InlineData(null, typeof(ArgumentNullException), "Value cannot be null")] + [InlineData("", typeof(ArgumentException), "Parameter cannot be an empty string")] + public void Constructor_WithInvalidScheduleId_ThrowsCorrectException(string invalidScheduleId, Type expectedExceptionType, string expectedMessage) + { + // Act & Assert + var ex = Assert.Throws(expectedExceptionType, () => + new DefaultScheduleClient(this.durableTaskClient.Object, invalidScheduleId, this.logger)); + + Assert.Contains(expectedMessage, ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + var ex = Assert.Throws(() => + new DefaultScheduleClient(this.durableTaskClient.Object, this.scheduleId, null!)); + Assert.Equal("logger", ex.ParamName); + } + + [Fact] + public async Task DescribeAsync_WhenExists_ReturnsDescription() + { + // Arrange + var state = new ScheduleState + { + Status = ScheduleStatus.Active, + ScheduleConfiguration = new ScheduleConfiguration(this.scheduleId, "test-orchestration", TimeSpan.FromMinutes(5)) + }; + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), this.scheduleId); + // get key and id + var key = entityInstanceId.Key; + var id = entityInstanceId.Name; + + this.entityClient + .Setup(c => c.GetEntityAsync( + It.Is(id => id.Name == entityInstanceId.Name && id.Key == entityInstanceId.Key), + It.IsAny())) + .ReturnsAsync(new EntityMetadata(entityInstanceId, state)); + + // Act + var description = await this.client.DescribeAsync(); + + // Assert + Assert.NotNull(description); + Assert.Equal(this.scheduleId, description.ScheduleId); + Assert.Equal(state.Status, description.Status); + Assert.Equal(state.ScheduleConfiguration.OrchestrationName, description.OrchestrationName); + } + + [Fact] + public async Task DescribeAsync_WhenNotExists_ThrowsScheduleNotFoundException() + { + // Arrange + this.entityClient + .Setup(c => c.GetEntityAsync( + It.Is(id => id.Name == nameof(Schedule) && id.Key == this.scheduleId), + It.IsAny())) + .ReturnsAsync((EntityMetadata)null!); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => this.client.DescribeAsync()); + Assert.Equal(this.scheduleId, ex.ScheduleId); + } + + [Fact] + public async Task DeleteAsync_ExecutesDeleteOperation() + { + // Arrange + string instanceId = "test-instance"; + + this.durableTaskClient + .Setup(c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(instanceId); + + this.durableTaskClient + .Setup(c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny())) + .ReturnsAsync(new OrchestrationMetadata(nameof(ExecuteScheduleOperationOrchestrator), instanceId) + { + RuntimeStatus = OrchestrationRuntimeStatus.Completed + }); + + // Act + await this.client.DeleteAsync(); + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), this.scheduleId); + // Assert + this.durableTaskClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.Is(r => + r.EntityId.Name == entityInstanceId.Name && + r.EntityId.Key == entityInstanceId.Key && + r.OperationName == "delete"), + It.IsAny()), // Ensure all arguments match + Times.Once); + + + // Verify that we waited for completion + this.durableTaskClient.Verify( + c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DeleteAsync_WhenOrchestrationFails_ThrowsException() + { + // Arrange + string instanceId = "test-instance"; + string errorMessage = "Test error message"; + + this.durableTaskClient + .Setup(c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(instanceId); + + this.durableTaskClient + .Setup(c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny())) + .ReturnsAsync(new OrchestrationMetadata(nameof(ExecuteScheduleOperationOrchestrator), instanceId) + { + RuntimeStatus = OrchestrationRuntimeStatus.Failed, + FailureDetails = new TaskFailureDetails("TestError", errorMessage, null, null) + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => this.client.DeleteAsync()); + + Assert.Contains($"Failed to delete schedule '{this.scheduleId}'", exception.Message); + Assert.Contains(errorMessage, exception.Message); + } + + [Fact] + public async Task PauseAsync_ExecutesPauseOperation() + { + // Arrange + string instanceId = "test-instance"; + + this.durableTaskClient + .Setup(c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(instanceId); + + this.durableTaskClient + .Setup(c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny())) + .ReturnsAsync(new OrchestrationMetadata(nameof(ExecuteScheduleOperationOrchestrator), instanceId) { + RuntimeStatus = OrchestrationRuntimeStatus.Completed + }); + + // Act + await this.client.PauseAsync(); + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), this.scheduleId); + // Assert + this.durableTaskClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.Is(r => + r.EntityId.Name == entityInstanceId.Name && + r.EntityId.Key == entityInstanceId.Key && + r.OperationName == nameof(Schedule.PauseSchedule)), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ResumeAsync_ExecutesResumeOperation() + { + // Arrange + string instanceId = "test-instance"; + + this.durableTaskClient + .Setup(c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(instanceId); + + this.durableTaskClient + .Setup(c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny())) + .ReturnsAsync(new OrchestrationMetadata(nameof(ExecuteScheduleOperationOrchestrator), instanceId) { + RuntimeStatus = OrchestrationRuntimeStatus.Completed + }); + + // Act + await this.client.ResumeAsync(); + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), this.scheduleId); + // Assert + this.durableTaskClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.Is(r => + r.EntityId.Name == entityInstanceId.Name && + r.EntityId.Key == entityInstanceId.Key && + r.OperationName == nameof(Schedule.ResumeSchedule)), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task UpdateAsync_ExecutesUpdateOperation() + { + // Arrange + string instanceId = "test-instance"; + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationName = "new-orchestration", + Interval = TimeSpan.FromMinutes(10) + }; + + this.durableTaskClient + .Setup(c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(instanceId); + + this.durableTaskClient + .Setup(c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny())) + .ReturnsAsync(new OrchestrationMetadata(nameof(ExecuteScheduleOperationOrchestrator), instanceId) { + RuntimeStatus = OrchestrationRuntimeStatus.Completed + }); + + // Act + await this.client.UpdateAsync(updateOptions); + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), this.scheduleId); + // Assert + this.durableTaskClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.Is(r => + r.EntityId.Name == entityInstanceId.Name && + r.EntityId.Key == entityInstanceId.Key && + r.OperationName == nameof(Schedule.UpdateSchedule)), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task UpdateAsync_WithNullOptions_ThrowsArgumentNullException() + { + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => this.client.UpdateAsync(null!)); + Assert.Equal("updateOptions", ex.ParamName); + } +} \ No newline at end of file diff --git a/test/ScheduledTasks.Tests/Client/DefaultScheduledTaskClientTests.cs b/test/ScheduledTasks.Tests/Client/DefaultScheduledTaskClientTests.cs new file mode 100644 index 00000000..4eeada24 --- /dev/null +++ b/test/ScheduledTasks.Tests/Client/DefaultScheduledTaskClientTests.cs @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.DurableTask.ScheduledTasks.Tests.Client; + +public class DefaultScheduledTaskClientTests +{ + readonly Mock durableTaskClient; + readonly Mock entityClient; + readonly ILogger logger; + readonly DefaultScheduledTaskClient client; + + public DefaultScheduledTaskClientTests() + { + this.durableTaskClient = new Mock("test"); + this.entityClient = new Mock("test"); + this.logger = new TestLogger(); + this.durableTaskClient.Setup(x => x.Entities).Returns(this.entityClient.Object); + this.client = new DefaultScheduledTaskClient(this.durableTaskClient.Object, this.logger); + } + + [Fact] + public void Constructor_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var ex = Assert.Throws(() => new DefaultScheduledTaskClient(null!, this.logger)); + Assert.Equal("durableTaskClient", ex.ParamName); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + var ex = Assert.Throws(() => new DefaultScheduledTaskClient(this.durableTaskClient.Object, null!)); + Assert.Equal("logger", ex.ParamName); + } + + [Fact] + public void GetScheduleClient_ReturnsValidClient() + { + // Arrange + string scheduleId = "test-schedule"; + + // Act + var scheduleClient = this.client.GetScheduleClient(scheduleId); + + // Assert + Assert.NotNull(scheduleClient); + Assert.Equal(scheduleId, scheduleClient.ScheduleId); + } + + [Theory] + [InlineData(null, typeof(ArgumentNullException), "Value cannot be null")] + [InlineData("", typeof(ArgumentException), "Parameter cannot be an empty string")] + public void GetScheduleClient_WithInvalidId_ThrowsCorrectException(string scheduleId, Type expectedExceptionType, string expectedMessage) + { + // Act & Assert + var ex = Assert.Throws(expectedExceptionType, () => this.client.GetScheduleClient(scheduleId)); + Assert.Contains(expectedMessage, ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CreateScheduleAsync_WithValidOptions_CreatesSchedule() + { + // Arrange + var options = new ScheduleCreationOptions("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)); + string instanceId = "test-instance"; + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), options.ScheduleId); + + this.durableTaskClient + .Setup(x => x.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.IsAny(), + default)) + .ReturnsAsync(instanceId); + + this.durableTaskClient + .Setup(x => x.WaitForInstanceCompletionAsync(instanceId, true, default)) + .ReturnsAsync(new OrchestrationMetadata(nameof(ExecuteScheduleOperationOrchestrator), instanceId) + { + RuntimeStatus = OrchestrationRuntimeStatus.Completed + }); + + // Act + var scheduleClient = await this.client.CreateScheduleAsync(options); + + // Assert + Assert.NotNull(scheduleClient); + Assert.Equal(options.ScheduleId, scheduleClient.ScheduleId); + + this.durableTaskClient.Verify( + x => x.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == nameof(ExecuteScheduleOperationOrchestrator)), + It.Is(r => + r.EntityId.Name == entityInstanceId.Name && + r.EntityId.Key == entityInstanceId.Key && + r.OperationName == nameof(Schedule.CreateSchedule) && + ((ScheduleCreationOptions)r.Input).ScheduleId == options.ScheduleId && + ((ScheduleCreationOptions)r.Input).OrchestrationName == options.OrchestrationName && + ((ScheduleCreationOptions)r.Input).Interval == options.Interval), + default), + Times.Once); + } + + [Fact] + public async Task CreateScheduleAsync_WithNullOptions_ThrowsArgumentNullException() + { + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => this.client.CreateScheduleAsync(null!)); + Assert.Equal("creationOptions", ex.ParamName); + } + + [Fact] + public async Task GetScheduleAsync_WhenExists_ReturnsDescription() + { + // Arrange + // create now + var now = DateTimeOffset.UtcNow; + string scheduleId = "test-schedule"; + var config = new ScheduleConfiguration(scheduleId, "test-orchestration", TimeSpan.FromMinutes(5)) + { + StartAt = now.AddMinutes(-10), + EndAt = now.AddDays(1), + StartImmediatelyIfLate = true, + OrchestrationInput = "test-input", + OrchestrationInstanceId = "test-instance" + }; + + var state = new ScheduleState + { + Status = ScheduleStatus.Active, + ScheduleConfiguration = config, + ExecutionToken = "test-token", + LastRunAt = now.AddMinutes(-5), + NextRunAt = now.AddMinutes(5), + ScheduleCreatedAt = now.AddDays(-1) + }; + + var metadata = new EntityMetadata( + new EntityInstanceId(nameof(Schedule), scheduleId), + state); + + this.durableTaskClient + .Setup(x => x.Entities) + .Returns(this.entityClient.Object); + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), scheduleId); + + this.entityClient + .Setup(x => x.GetEntityAsync( + It.Is(id => id.Name == entityInstanceId.Name && id.Key == entityInstanceId.Key), + default)) + .ReturnsAsync(metadata); + + // Act + var description = await this.client.GetScheduleAsync(scheduleId); + + //verify getentityasync is called + this.entityClient.Verify(x => x.GetEntityAsync(entityInstanceId, default), Times.Once); + + // Assert + Assert.NotNull(description); + Assert.Equal(scheduleId, description.ScheduleId); + Assert.Equal(ScheduleStatus.Active, description.Status); + Assert.Equal(config.OrchestrationName, description.OrchestrationName); + Assert.Equal(config.OrchestrationInput, description.OrchestrationInput); + Assert.Equal(config.OrchestrationInstanceId, description.OrchestrationInstanceId); + Assert.Equal(config.StartAt, description.StartAt); + Assert.Equal(config.EndAt, description.EndAt); + Assert.Equal(config.Interval, description.Interval); + Assert.Equal(config.StartImmediatelyIfLate, description.StartImmediatelyIfLate); + Assert.Equal("test-token", description.ExecutionToken); + Assert.Equal(now.AddMinutes(-5), description.LastRunAt); + Assert.Equal(now.AddMinutes(5), description.NextRunAt); + } + + [Fact] + public async Task GetScheduleAsync_WhenNotExists_ReturnsNull() + { + // Arrange + string scheduleId = "test-schedule"; + + // create entity instance id + var entityInstanceId = new EntityInstanceId(nameof(Schedule), scheduleId); + + this.entityClient + .Setup(x => x.GetEntityAsync( + It.Is(id => id.Name == entityInstanceId.Name && id.Key == entityInstanceId.Key), + true, + default)) + .ReturnsAsync((EntityMetadata?)null); + + // Act + var description = await this.client.GetScheduleAsync(scheduleId); + + // Assert + Assert.Null(description); + } + + [Fact] + public async Task ListSchedulesAsync_ReturnsSchedules() + { + // Arrange + var query = new ScheduleQuery + { + ScheduleIdPrefix = "test", + Status = ScheduleStatus.Active, + PageSize = 10 + }; + + var states = new[] + { + new EntityMetadata( + new EntityInstanceId(nameof(Schedule), "test-1"), + new ScheduleState + { + Status = ScheduleStatus.Active, + ScheduleConfiguration = new ScheduleConfiguration("test-1", "test-orchestration", TimeSpan.FromMinutes(5)) + { + StartAt = DateTimeOffset.UtcNow.AddMinutes(-10), + EndAt = DateTimeOffset.UtcNow.AddDays(1), + StartImmediatelyIfLate = true, + OrchestrationInput = "test-input-1", + OrchestrationInstanceId = "test-instance-1" + }, + ExecutionToken = "test-token-1", + LastRunAt = DateTimeOffset.UtcNow.AddMinutes(-5), + NextRunAt = DateTimeOffset.UtcNow.AddMinutes(5), + ScheduleCreatedAt = DateTimeOffset.UtcNow.AddDays(-1) + }), + new EntityMetadata( + new EntityInstanceId(nameof(Schedule), "test-2"), + new ScheduleState + { + Status = ScheduleStatus.Active, + ScheduleConfiguration = new ScheduleConfiguration("test-2", "test-orchestration", TimeSpan.FromMinutes(5)) + { + StartAt = DateTimeOffset.UtcNow.AddMinutes(-8), + EndAt = DateTimeOffset.UtcNow.AddDays(2), + StartImmediatelyIfLate = true, + OrchestrationInput = "test-input-2", + OrchestrationInstanceId = "test-instance-2" + }, + ExecutionToken = "test-token-2", + LastRunAt = DateTimeOffset.UtcNow.AddMinutes(-3), + NextRunAt = DateTimeOffset.UtcNow.AddMinutes(7), + ScheduleCreatedAt = DateTimeOffset.UtcNow.AddDays(-2) + }) + }; + + this.durableTaskClient + .Setup(x => x.Entities) + .Returns(this.entityClient.Object); + + this.entityClient + .Setup(x => x.GetAllEntitiesAsync( + It.IsAny())) + .Returns(Pageable.Create>((continuation, pageSize, cancellation) => + { + var page = new Page>(states, continuation); + return Task.FromResult(page); + })); + + // Act + var schedules = new List(); + await foreach (var schedule in this.client.ListSchedulesAsync(query)) + { + schedules.Add(schedule); + } + + // Assert + // verify getallentitiesasync is called + this.entityClient.Verify(x => x.GetAllEntitiesAsync( + It.Is(q => + q.InstanceIdStartsWith == $"@schedule@test" && + q.IncludeState == true && + q.PageSize == query.PageSize)), Times.Once); + + Assert.Equal(2, schedules.Count); + Assert.All(schedules, s => Assert.StartsWith("test-", s.ScheduleId)); + Assert.All(schedules, s => Assert.Equal(ScheduleStatus.Active, s.Status)); + Assert.All(schedules, s => Assert.NotNull(s.ExecutionToken)); + Assert.All(schedules, s => Assert.NotNull(s.LastRunAt)); + Assert.All(schedules, s => Assert.NotNull(s.NextRunAt)); + } +} \ No newline at end of file diff --git a/test/ScheduledTasks.Tests/Entity/ScheduleTests.cs b/test/ScheduledTasks.Tests/Entity/ScheduleTests.cs new file mode 100644 index 00000000..ef7ae7d0 --- /dev/null +++ b/test/ScheduledTasks.Tests/Entity/ScheduleTests.cs @@ -0,0 +1,1794 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Entities.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DurableTask.ScheduledTasks.Tests.Entity; + +public class ScheduleTests +{ + readonly Schedule schedule; + readonly string scheduleId = "test-schedule"; + readonly TestLogger logger; + + public ScheduleTests(ITestOutputHelper output) + { + this.logger = new TestLogger(); + this.schedule = new Schedule(this.logger); + } + + [Fact] + public async Task CreateSchedule_WithValidOptions_CreatesSchedule() + { + // Arrange + ScheduleCreationOptions options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create test operation + TestEntityOperation operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + ScheduleState scheduleState = Assert.IsType(state); + Assert.NotNull(scheduleState.ScheduleConfiguration); + Assert.Equal(this.scheduleId, scheduleState.ScheduleConfiguration.ScheduleId); + Assert.Equal("TestOrchestration", scheduleState.ScheduleConfiguration.OrchestrationName); + Assert.Equal(TimeSpan.FromMinutes(5), scheduleState.ScheduleConfiguration.Interval); + Assert.Equal(ScheduleStatus.Active, scheduleState.Status); + } + + [Fact] + public async Task PauseSchedule_WhenAlreadyPaused_ThrowsInvalidTransitionException() + { + // Arrange + ScheduleCreationOptions createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create initial state + TestEntityOperation createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // assert after create + var state = createOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + ScheduleState scheduleState = Assert.IsType(state); + Assert.Equal(ScheduleStatus.Active, scheduleState.Status); + + // Pause first time + TestEntityOperation pauseOperation = new TestEntityOperation( + nameof(Schedule.PauseSchedule), + createOperation.State, + null); + await this.schedule.RunAsync(pauseOperation); + + // assert after first pause + var stateAfterFirstPause = pauseOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterFirstPause); + ScheduleState scheduleStateAfterFirstPause = Assert.IsType(stateAfterFirstPause); + Assert.Equal(ScheduleStatus.Paused, scheduleStateAfterFirstPause.Status); + + // Pause second time + TestEntityOperation pauseOperation2 = new TestEntityOperation( + nameof(Schedule.PauseSchedule), + pauseOperation.State, + null); + + // Act & Assert + await Assert.ThrowsAsync(() => + this.schedule.RunAsync(new TestEntityOperation( + nameof(Schedule.PauseSchedule), + pauseOperation2.State, + null)).AsTask()); + } + + [Fact] + public async Task ResumeSchedule_WhenPaused_ResumesSchedule() + { + // Arrange + ScheduleCreationOptions createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create initial state + TestEntityOperation createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Assert initial state is active + var stateAfterCreate = createOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterCreate); + ScheduleState scheduleStateAfterCreate = Assert.IsType(stateAfterCreate); + Assert.Equal(ScheduleStatus.Active, scheduleStateAfterCreate.Status); + + // Pause + TestEntityOperation pauseOperation = new TestEntityOperation( + nameof(Schedule.PauseSchedule), + createOperation.State, + null); + await this.schedule.RunAsync(pauseOperation); + + // Assert paused state + var stateAfterPause = pauseOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterPause); + ScheduleState scheduleStateAfterPause = Assert.IsType(stateAfterPause); + Assert.Equal(ScheduleStatus.Paused, scheduleStateAfterPause.Status); + + // Act + TestEntityOperation resumeOperation = new TestEntityOperation( + nameof(Schedule.ResumeSchedule), + pauseOperation.State, + null); + await this.schedule.RunAsync(resumeOperation); + + // Assert resumed state + var stateAfterResume = resumeOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterResume); + ScheduleState scheduleStateAfterResume = Assert.IsType(stateAfterResume); + Assert.Equal(ScheduleStatus.Active, scheduleStateAfterResume.Status); + } + + [Fact] + public async Task ResumeSchedule_WhenActive_ThrowsInvalidTransitionException() + { + // Arrange + ScheduleCreationOptions createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create initial state + TestEntityOperation createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Assert initial state is active + object? stateAfterCreate = createOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterCreate); + ScheduleState scheduleStateAfterCreate = Assert.IsType(stateAfterCreate); + Assert.Equal(ScheduleStatus.Active, scheduleStateAfterCreate.Status); + + // Act & Assert + await Assert.ThrowsAsync(() => + this.schedule.RunAsync(new TestEntityOperation( + nameof(Schedule.ResumeSchedule), + createOperation.State, + null)).AsTask()); + } + + [Fact] + public async Task UpdateSchedule_WithValidOptions_UpdatesSchedule() + { + // Arrange + ScheduleCreationOptions createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create initial state + TestEntityOperation createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Assert initial state + object? stateAfterCreate = createOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterCreate); + ScheduleState scheduleStateAfterCreate = Assert.IsType(stateAfterCreate); + Assert.Equal(TimeSpan.FromMinutes(5), scheduleStateAfterCreate.ScheduleConfiguration?.Interval); + + ScheduleUpdateOptions updateOptions = new ScheduleUpdateOptions + { + Interval = TimeSpan.FromMinutes(10) + }; + + // Act + TestEntityOperation updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert updated state + object? stateAfterUpdate = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterUpdate); + ScheduleState scheduleStateAfterUpdate = Assert.IsType(stateAfterUpdate); + Assert.Equal(TimeSpan.FromMinutes(10), scheduleStateAfterUpdate.ScheduleConfiguration?.Interval); + } + + [Fact] + public async Task UpdateSchedule_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + ScheduleCreationOptions createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create initial state + TestEntityOperation createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Assert initial state + object? stateAfterCreate = createOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterCreate); + ScheduleState scheduleStateAfterCreate = Assert.IsType(stateAfterCreate); + Assert.Equal(TimeSpan.FromMinutes(5), scheduleStateAfterCreate.ScheduleConfiguration?.Interval); + + // Act & Assert + await Assert.ThrowsAsync(() => + this.schedule.RunAsync(new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + stateAfterCreate, + null)).AsTask()); + } + + [Fact] + public async Task RunSchedule_WhenNotActive_ThrowsInvalidOperationException() + { + // Arrange + ScheduleCreationOptions createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create initial state + TestEntityOperation createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Assert initial state is active + object? stateAfterCreate = createOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterCreate); + ScheduleState scheduleStateAfterCreate = Assert.IsType(stateAfterCreate); + Assert.Equal(ScheduleStatus.Active, scheduleStateAfterCreate.Status); + + // Pause + TestEntityOperation pauseOperation = new TestEntityOperation( + nameof(Schedule.PauseSchedule), + createOperation.State, + null); + await this.schedule.RunAsync(pauseOperation); + + // Assert paused state + object? stateAfterPause = pauseOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterPause); + ScheduleState scheduleStateAfterPause = Assert.IsType(stateAfterPause); + Assert.Equal(ScheduleStatus.Paused, scheduleStateAfterPause.Status); + + // run schedule op + TestEntityOperation runOp = new TestEntityOperation( + nameof(Schedule.RunSchedule), + scheduleStateAfterPause, + scheduleStateAfterPause.ExecutionToken); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + this.schedule.RunAsync(runOp).AsTask()); + // check exception message Schedule must be in Active status to run. + Assert.Contains("Schedule must be in Active status to run.", exception.Message); + } + + [Fact] + public async Task RunSchedule_WithInvalidToken_DoesNotRun() + { + // Arrange + ScheduleCreationOptions createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create initial state + TestEntityOperation createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Assert initial state + object? stateAfterCreate = createOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterCreate); + ScheduleState scheduleStateAfterCreate = Assert.IsType(stateAfterCreate); + Assert.Equal(ScheduleStatus.Active, scheduleStateAfterCreate.Status); + string? initialToken = scheduleStateAfterCreate.ExecutionToken; + + // Act + TestEntityOperation runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + "invalid-token"); + await this.schedule.RunAsync(runOperation); + + // Assert state unchanged + object? stateAfterRun = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(stateAfterRun); + ScheduleState scheduleStateAfterRun = Assert.IsType(stateAfterRun); + Assert.Equal(ScheduleStatus.Active, scheduleStateAfterRun.Status); + Assert.Equal(initialToken, scheduleStateAfterRun.ExecutionToken); + Assert.Null(scheduleStateAfterRun.LastRunAt); + } + + [Fact] + public async Task CreateSchedule_WithStartAt_SetsNextRunTimeCorrectly() + { + // Arrange + var startAt = DateTimeOffset.UtcNow.AddMinutes(5); + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartAt = startAt + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(startAt, scheduleState.ScheduleConfiguration?.StartAt); + Assert.Equal(ScheduleStatus.Active, scheduleState.Status); + } + + [Fact] + public async Task CreateSchedule_WithEndAt_SetsEndTimeCorrectly() + { + // Arrange + var endAt = DateTimeOffset.UtcNow.AddHours(1); + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + EndAt = endAt + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(endAt, scheduleState.ScheduleConfiguration?.EndAt); + } + + [Fact] + public async Task CreateSchedule_WithStartImmediatelyIfLate_SetsPropertyCorrectly() + { + // Arrange + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartImmediatelyIfLate = true + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.True(scheduleState.ScheduleConfiguration?.StartImmediatelyIfLate); + } + + [Fact] + public async Task UpdateSchedule_WithStartAt_UpdatesStartTimeCorrectly() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var newStartAt = DateTimeOffset.UtcNow.AddMinutes(10); + var updateOptions = new ScheduleUpdateOptions + { + StartAt = newStartAt + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(newStartAt, scheduleState.ScheduleConfiguration?.StartAt); + Assert.Null(scheduleState.NextRunAt); // NextRunAt should be reset + } + + [Fact] + public async Task UpdateSchedule_WithEndAt_UpdatesEndTimeCorrectly() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var newEndAt = DateTimeOffset.UtcNow.AddHours(2); + var updateOptions = new ScheduleUpdateOptions + { + EndAt = newEndAt + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(newEndAt, scheduleState.ScheduleConfiguration?.EndAt); + } + + [Fact] + public async Task UpdateSchedule_WithNoChanges_DoesNotUpdateState() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var initialState = createOperation.State.GetState(typeof(ScheduleState)); + var initialScheduleState = Assert.IsType(initialState); + var initialModifiedTime = initialScheduleState.ScheduleLastModifiedAt; + + var updateOptions = new ScheduleUpdateOptions + { + Interval = TimeSpan.FromMinutes(5) // Same interval + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(initialModifiedTime, scheduleState.ScheduleLastModifiedAt); + } + + [Fact] + public async Task RunSchedule_WhenEndAtPassed_DoesNotRun() + { + // Arrange + var endAt = DateTimeOffset.UtcNow.AddMinutes(-5); // End time in the past + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + EndAt = endAt + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + createOperation.State.GetState()?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.LastRunAt); + Assert.Null(scheduleState.NextRunAt); + } + + [Fact] + public async Task RunSchedule_WithValidToken_UpdatesLastRunAt() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartImmediatelyIfLate = true + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var initialState = createOperation.State.GetState(); + var validToken = initialState?.ExecutionToken; + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + validToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.NotNull(scheduleState.LastRunAt); + } + + [Fact] + public async Task CreateSchedule_WithOrchestrationInput_SetsInputCorrectly() + { + // Arrange + var orchestrationInput = "test-input"; + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + OrchestrationInput = orchestrationInput + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(orchestrationInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task CreateSchedule_WithOrchestrationInstanceId_SetsIdCorrectly() + { + // Arrange + var instanceId = "custom-instance-id"; + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + OrchestrationInstanceId = instanceId + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(instanceId, scheduleState.ScheduleConfiguration?.OrchestrationInstanceId); + } + + [Fact] + public async Task UpdateSchedule_WithOrchestrationInput_UpdatesInputCorrectly() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var newInput = "new-test-input"; + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationInput = newInput + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(newInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task UpdateSchedule_WithStartImmediatelyIfLate_UpdatesPropertyCorrectly() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var updateOptions = new ScheduleUpdateOptions + { + StartImmediatelyIfLate = true + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.True(scheduleState.ScheduleConfiguration?.StartImmediatelyIfLate); + } + + [Fact] + public async Task RunSchedule_WhenStartAtInFuture_DoesNotRunImmediately() + { + // Arrange + var startAt = DateTimeOffset.UtcNow.AddMinutes(5); + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartAt = startAt + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + createOperation.State.GetState()?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.LastRunAt); + Assert.NotNull(scheduleState.NextRunAt); + Assert.True(scheduleState.NextRunAt >= startAt); + } + + [Fact] + public async Task RunSchedule_WithStartImmediatelyIfLate_RunsImmediatelyWhenLate() + { + // Arrange + var startAt = DateTimeOffset.UtcNow.AddMinutes(-5); // Start time in the past + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartAt = startAt, + StartImmediatelyIfLate = true + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + createOperation.State.GetState()?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.NotNull(scheduleState.LastRunAt); + Assert.True(scheduleState.LastRunAt >= DateTimeOffset.UtcNow.AddSeconds(-1)); + } + + [Fact] + public async Task RunSchedule_WithStartImmediatelyIfLate_False_DoesNotRunImmediatelyWhenLate() + { + // Arrange + var startAt = DateTimeOffset.UtcNow.AddMinutes(-5); // Start time in the past + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartAt = startAt, + StartImmediatelyIfLate = false + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + createOperation.State.GetState()?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.LastRunAt); + Assert.NotNull(scheduleState.NextRunAt); + Assert.True(scheduleState.NextRunAt > DateTimeOffset.UtcNow); + } + + [Fact] + public async Task CreateSchedule_WithEndAtBeforeStartAt_ThrowsArgumentException() + { + // Arrange + var startAt = DateTimeOffset.UtcNow.AddHours(2); + var endAt = DateTimeOffset.UtcNow.AddHours(1); // Before startAt + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartAt = startAt, + EndAt = endAt + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act & Assert + await Assert.ThrowsAsync(() => + this.schedule.RunAsync(operation).AsTask()); + } + + [Fact] + public async Task RunSchedule_WithExpiredEndAt_DoesNotUpdateLastRunAt() + { + // Arrange + var endAt = DateTimeOffset.UtcNow.AddSeconds(-1); + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + EndAt = endAt + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var initialState = createOperation.State.GetState(); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + initialState?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.LastRunAt); + } + + [Fact] + public async Task CreateSchedule_WithMaxInterval_CreatesSchedule() + { + // Arrange + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.MaxValue); + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(TimeSpan.MaxValue, scheduleState.ScheduleConfiguration?.Interval); + } + + [Fact] + public async Task UpdateSchedule_WithSameValues_DoesNotUpdateModifiedTime() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var initialState = createOperation.State.GetState(); + var initialModifiedTime = initialState?.ScheduleLastModifiedAt; + + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationName = "TestOrchestration", // Same name + Interval = TimeSpan.FromMinutes(5) // Same interval + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(initialModifiedTime, scheduleState.ScheduleLastModifiedAt); + } + + [Fact] + public async Task RunSchedule_WithStartAtInFutureAndStartImmediatelyIfLate_DoesNotRunImmediately() + { + // Arrange + var startAt = DateTimeOffset.UtcNow.AddMinutes(5); + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartAt = startAt, + StartImmediatelyIfLate = true // Should be ignored since StartAt is in future + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + createOperation.State.GetState()?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.LastRunAt); + Assert.Equal(startAt, scheduleState.NextRunAt); + } + + [Fact] + public async Task CreateSchedule_WithMaxDateTimeOffset_CreatesSchedule() + { + // Arrange + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + EndAt = DateTimeOffset.MaxValue + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(DateTimeOffset.MaxValue, scheduleState.ScheduleConfiguration?.EndAt); + } + + [Fact] + public async Task CreateSchedule_WithNullEndAt_ClearsEndAt() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + EndAt = DateTimeOffset.UtcNow.AddHours(1) + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var createOptions2 = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + EndAt = null + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + createOperation.State, + createOptions2); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.ScheduleConfiguration?.EndAt); + } + + [Fact] + public async Task RunSchedule_WithMultipleTokens_OnlyExecutesLatestToken() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartImmediatelyIfLate = true + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var initialState = createOperation.State.GetState(); + var initialToken = initialState?.ExecutionToken; + + // Update to get new token + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + new ScheduleUpdateOptions { Interval = TimeSpan.FromMinutes(6) }); + await this.schedule.RunAsync(updateOperation); + + var updatedState = updateOperation.State.GetState(); + var newToken = updatedState?.ExecutionToken; + + // Try to run with old token + var oldTokenOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + updateOperation.State, + initialToken); + await this.schedule.RunAsync(oldTokenOperation); + + // Assert old token operation didn't execute + var stateAfterOldToken = oldTokenOperation.State.GetState(); + Assert.Null(stateAfterOldToken?.LastRunAt); + + // Run with new token + var newTokenOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + oldTokenOperation.State, + newToken); + await this.schedule.RunAsync(newTokenOperation); + + // Assert new token operation executed + var finalState = newTokenOperation.State.GetState(); + Assert.NotNull(finalState?.LastRunAt); + } + + [Fact] + public async Task CreateSchedule_WithLongOrchestrationName_CreatesSchedule() + { + // Arrange + string longName = new string('a', 1000); // 1000 character name + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: longName, + interval: TimeSpan.FromMinutes(5)); + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(longName, scheduleState.ScheduleConfiguration?.OrchestrationName); + } + + [Fact] + public async Task UpdateSchedule_WithLargeInput_UpdatesInput() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + string largeInput = new string('x', 10000); // 10KB input + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationInput = largeInput + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(largeInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task RunSchedule_WithPreciseInterval_CalculatesNextRunTimeCorrectly() + { + // Arrange + var startAt = DateTimeOffset.UtcNow; + var interval = TimeSpan.FromSeconds(1.5); // 1.5 seconds + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: interval) + { + StartAt = startAt + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + createOperation.State.GetState()?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.NotNull(scheduleState.NextRunAt); + var expectedNextRun = startAt.Add(interval); + Assert.Equal(expectedNextRun.Ticks, scheduleState.NextRunAt.Value.Ticks); + } + + [Fact] + public async Task CreateSchedule_WithMinDateTimeOffset_CreatesSchedule() + { + // Arrange + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + StartAt = DateTimeOffset.MinValue + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(DateTimeOffset.MinValue, scheduleState.ScheduleConfiguration?.StartAt); + } + + [Fact] + public async Task UpdateSchedule_WithAllFieldsNull_DoesNotModifyState() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var initialState = createOperation.State.GetState(); + var updateOptions = new ScheduleUpdateOptions(); + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(initialState?.ScheduleConfiguration?.OrchestrationName, scheduleState.ScheduleConfiguration?.OrchestrationName); + Assert.Equal(initialState?.ScheduleConfiguration?.Interval, scheduleState.ScheduleConfiguration?.Interval); + Assert.Equal(initialState?.ScheduleConfiguration?.StartAt, scheduleState.ScheduleConfiguration?.StartAt); + Assert.Equal(initialState?.ScheduleConfiguration?.EndAt, scheduleState.ScheduleConfiguration?.EndAt); + Assert.Equal(initialState?.ScheduleConfiguration?.StartImmediatelyIfLate, scheduleState.ScheduleConfiguration?.StartImmediatelyIfLate); + } + + [Fact] + public async Task RunSchedule_WithIntervalSmallerThanTimeSinceStart_CalculatesCorrectNextRunTime() + { + // Arrange + var startAt = DateTimeOffset.UtcNow.AddMinutes(-10); // 10 minutes ago + var interval = TimeSpan.FromMinutes(3); // 3 minute interval + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: interval) + { + StartAt = startAt + }; + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + createOperation.State.GetState()?.ExecutionToken); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.NotNull(scheduleState.NextRunAt); + + // Calculate expected next run time + // Number of intervals elapsed = 10 minutes / 3 minutes = 3 intervals (rounded down) + // Next run should be at start time + (intervals elapsed + 1) * interval + var expectedNextRun = startAt.AddTicks((3 + 1) * interval.Ticks); + Assert.Equal(expectedNextRun, scheduleState.NextRunAt.Value); + } + + [Fact] + public async Task UpdateSchedule_WithEmptyOrchestrationName_NothingChange() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationName = "" + }; + + // Act & Assert + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // assert nothing changed + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal("TestOrchestration", scheduleState.ScheduleConfiguration?.OrchestrationName); + Assert.Equal(TimeSpan.FromMinutes(5), scheduleState.ScheduleConfiguration?.Interval); + Assert.Equal(createOperation.State.GetState()?.ScheduleConfiguration?.StartAt, scheduleState.ScheduleConfiguration?.StartAt); + Assert.Null(scheduleState.ScheduleConfiguration?.EndAt); + Assert.False(scheduleState.ScheduleConfiguration?.StartImmediatelyIfLate); + Assert.Equal(createOperation.State.GetState()?.ExecutionToken, scheduleState.ExecutionToken); + Assert.Equal(ScheduleStatus.Active, scheduleState.Status); + Assert.Equal(createOperation.State.GetState()?.LastRunAt, scheduleState.LastRunAt); + Assert.Equal(createOperation.State.GetState()?.NextRunAt, scheduleState.NextRunAt); + Assert.Equal(createOperation.State.GetState()?.ScheduleConfiguration?.OrchestrationInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + Assert.Equal(createOperation.State.GetState()?.ScheduleConfiguration?.OrchestrationInstanceId, scheduleState.ScheduleConfiguration?.OrchestrationInstanceId); + Assert.Equal(createOperation.State.GetState()?.ScheduleConfiguration?.OrchestrationName, scheduleState.ScheduleConfiguration?.OrchestrationName); + } + + [Fact] + public async Task RunSchedule_WithEmptyToken_DoesNotRun() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + ""); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.LastRunAt); + + Assert.Contains($"Schedule '{this.scheduleId}' run cancelled with execution token ''", this.logger.Logs.Last().Message); + } + + [Fact] + public async Task CreateSchedule_WithSpecialCharactersInScheduleId_CreatesSchedule() + { + // Arrange + var options = new ScheduleCreationOptions( + scheduleId: "test@schedule#123$%^", + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal("test@schedule#123$%^", scheduleState.ScheduleConfiguration?.ScheduleId); + } + + [Fact] + public async Task CreateSchedule_WithUnicodeCharactersInScheduleId_CreatesSchedule() + { + // Arrange + var options = new ScheduleCreationOptions( + scheduleId: "测试时间表", + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal("测试时间表", scheduleState.ScheduleConfiguration?.ScheduleId); + } + + [Fact] + public async Task UpdateSchedule_WithVeryLongOrchestrationInstanceId_UpdatesSuccessfully() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + string longInstanceId = new string('x', 1000); + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationInstanceId = longInstanceId + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(longInstanceId, scheduleState.ScheduleConfiguration?.OrchestrationInstanceId); + } + + [Fact] + public async Task RunSchedule_WithWhitespaceToken_DoesNotRun() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + // Act + var runOperation = new TestEntityOperation( + nameof(Schedule.RunSchedule), + createOperation.State, + " "); + await this.schedule.RunAsync(runOperation); + + // Assert + var state = runOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Null(scheduleState.LastRunAt); + Assert.Contains($"Schedule '{this.scheduleId}' run cancelled with execution token ' '", this.logger.Logs.Last().Message); + } + + [Fact] + public async Task CreateSchedule_WithMaxLengthScheduleId_CreatesSchedule() + { + // Arrange + string maxLengthId = new string('x', 1000); + var options = new ScheduleCreationOptions( + scheduleId: maxLengthId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(maxLengthId, scheduleState.ScheduleConfiguration?.ScheduleId); + } + + [Fact] + public async Task CreateSchedule_WithJsonSpecialCharactersInInput_CreatesSchedule() + { + // Arrange + string specialInput = "{\"key\":\"value\",\"array\":[1,2,3],\"nested\":{\"prop\":true}}"; + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + OrchestrationInput = specialInput + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(specialInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task UpdateSchedule_WithJsonSpecialCharactersInInput_UpdatesSuccessfully() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + string specialInput = "{\"key\":\"value\",\"array\":[1,2,3],\"nested\":{\"prop\":true}}"; + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationInput = specialInput + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(specialInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task CreateSchedule_WithHtmlSpecialCharactersInInput_CreatesSchedule() + { + // Arrange + string htmlInput = "
Hello & World
"; + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + OrchestrationInput = htmlInput + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(htmlInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task UpdateSchedule_WithHtmlSpecialCharactersInInput_UpdatesSuccessfully() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + string htmlInput = "
Hello & World
"; + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationInput = htmlInput + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(htmlInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task CreateSchedule_WithMultilineInput_CreatesSchedule() + { + // Arrange + string multilineInput = @"Line 1 +Line 2 +Line 3 +With special chars: !@#$%^&*()"; + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + OrchestrationInput = multilineInput + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(multilineInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task UpdateSchedule_WithMultilineInput_UpdatesSuccessfully() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + string multilineInput = @"Line 1 +Line 2 +Line 3 +With special chars: !@#$%^&*()"; + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationInput = multilineInput + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(multilineInput, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task CreateSchedule_WithBase64EncodedInput_CreatesSchedule() + { + // Arrange + string base64Input = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("Hello World")); + var options = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)) + { + OrchestrationInput = base64Input + }; + + // Create test operation + var operation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + options); + + // Act + await this.schedule.RunAsync(operation); + + // Assert + var state = operation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(base64Input, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } + + [Fact] + public async Task UpdateSchedule_WithBase64EncodedInput_UpdatesSuccessfully() + { + // Arrange + var createOptions = new ScheduleCreationOptions( + scheduleId: this.scheduleId, + orchestrationName: "TestOrchestration", + interval: TimeSpan.FromMinutes(5)); + + var createOperation = new TestEntityOperation( + nameof(Schedule.CreateSchedule), + new TestEntityState(null), + createOptions); + await this.schedule.RunAsync(createOperation); + + string base64Input = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("Hello World")); + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationInput = base64Input + }; + + // Act + var updateOperation = new TestEntityOperation( + nameof(Schedule.UpdateSchedule), + createOperation.State, + updateOptions); + await this.schedule.RunAsync(updateOperation); + + // Assert + var state = updateOperation.State.GetState(typeof(ScheduleState)); + Assert.NotNull(state); + var scheduleState = Assert.IsType(state); + Assert.Equal(base64Input, scheduleState.ScheduleConfiguration?.OrchestrationInput); + } +} \ No newline at end of file diff --git a/test/ScheduledTasks.Tests/Models/ScheduleConfigurationTests.cs b/test/ScheduledTasks.Tests/Models/ScheduleConfigurationTests.cs new file mode 100644 index 00000000..b5bf2881 --- /dev/null +++ b/test/ScheduledTasks.Tests/Models/ScheduleConfigurationTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.DurableTask.ScheduledTasks.Tests.Models; + +public class ScheduleConfigurationTests +{ + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange + string scheduleId = "test-schedule"; + string orchestrationName = "test-orchestration"; + TimeSpan interval = TimeSpan.FromMinutes(5); + + // Act + var config = new ScheduleConfiguration(scheduleId, orchestrationName, interval); + + // Assert + Assert.Equal(scheduleId, config.ScheduleId); + Assert.Equal(orchestrationName, config.OrchestrationName); + Assert.Equal(interval, config.Interval); + } + + [Theory] + [InlineData(null, "orchestration", typeof(ArgumentNullException), "Value cannot be null")] + [InlineData("", "orchestration", typeof(ArgumentException), "Parameter cannot be an empty string or start with the null character")] + [InlineData("schedule", null, typeof(ArgumentNullException), "Value cannot be null")] + [InlineData("schedule", "", typeof(ArgumentException), "Parameter cannot be an empty string or start with the null character")] + public void Constructor_WithInvalidParameters_ThrowsException(string scheduleId, string orchestrationName, Type expectedExceptionType, string expectedMessage) + { + // Arrange + TimeSpan interval = TimeSpan.FromMinutes(5); + + // Act & Assert + var ex = Assert.Throws(expectedExceptionType, () => new ScheduleConfiguration(scheduleId, orchestrationName, interval)); + Assert.Contains(expectedMessage, ex.Message); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + public void Constructor_WithNonPositiveInterval_ThrowsArgumentException(int seconds) + { + // Arrange + string scheduleId = "test-schedule"; + string orchestrationName = "test-orchestration"; + TimeSpan interval = TimeSpan.FromSeconds(seconds); + + // Act & Assert + var ex = Assert.Throws(() => new ScheduleConfiguration(scheduleId, orchestrationName, interval)); + Assert.Contains("Interval must be positive", ex.Message); + } + + [Fact] + public void Constructor_WithIntervalLessThanOneSecond_ThrowsArgumentException() + { + // Arrange + string scheduleId = "test-schedule"; + string orchestrationName = "test-orchestration"; + TimeSpan interval = TimeSpan.FromMilliseconds(500); + + // Act & Assert + var ex = Assert.Throws(() => new ScheduleConfiguration(scheduleId, orchestrationName, interval)); + Assert.Contains("Interval must be at least 1 second", ex.Message); + } + + [Fact] + public void OrchestrationName_SetToNull_ThrowsArgumentException() + { + // Arrange + var config = new ScheduleConfiguration("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)); + + // Act & Assert + var ex = Assert.Throws(() => config.OrchestrationName = null!); + Assert.Contains("Value cannot be null.", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Interval_SetToInvalidValue_ThrowsArgumentException() + { + // Arrange + var config = new ScheduleConfiguration("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)); + + // Act & Assert + var ex = Assert.Throws(() => config.Interval = TimeSpan.Zero); + Assert.Contains("Interval must be positive", ex.Message); + + ex = Assert.Throws(() => config.Interval = TimeSpan.FromMilliseconds(500)); + Assert.Contains("Interval must be at least 1 second", ex.Message); + } + + [Fact] + public void FromCreateOptions_WithValidOptions_CreatesConfiguration() + { + // Arrange + var options = new ScheduleCreationOptions("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)) + { + OrchestrationInput = "test-input", + OrchestrationInstanceId = "test-instance", + StartAt = DateTimeOffset.UtcNow, + EndAt = DateTimeOffset.UtcNow.AddDays(1), + StartImmediatelyIfLate = true + }; + + // Act + var config = ScheduleConfiguration.FromCreateOptions(options); + + // Assert + Assert.Equal(options.ScheduleId, config.ScheduleId); + Assert.Equal(options.OrchestrationName, config.OrchestrationName); + Assert.Equal(options.Interval, config.Interval); + Assert.Equal(options.OrchestrationInput, config.OrchestrationInput); + Assert.Equal(options.OrchestrationInstanceId, config.OrchestrationInstanceId); + Assert.Equal(options.StartAt, config.StartAt); + Assert.Equal(options.EndAt, config.EndAt); + Assert.Equal(options.StartImmediatelyIfLate, config.StartImmediatelyIfLate); + } + + [Fact] + public void FromCreateOptions_WithNullOptions_ThrowsArgumentNullException() + { + // Act & Assert + var ex = Assert.Throws(() => ScheduleConfiguration.FromCreateOptions(null!)); + Assert.Equal("createOptions", ex.ParamName); + } + + [Fact] + public void Update_WithValidOptions_UpdatesConfiguration() + { + // Arrange + var config = new ScheduleConfiguration("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)); + var updateOptions = new ScheduleUpdateOptions + { + OrchestrationName = "new-orchestration", + OrchestrationInput = "new-input", + OrchestrationInstanceId = "new-instance", + StartAt = DateTimeOffset.UtcNow, + EndAt = DateTimeOffset.UtcNow.AddDays(1), + Interval = TimeSpan.FromMinutes(10), + StartImmediatelyIfLate = true + }; + + // Act + var updatedFields = config.Update(updateOptions); + + // Assert + Assert.Equal(updateOptions.OrchestrationName, config.OrchestrationName); + Assert.Equal(updateOptions.OrchestrationInput, config.OrchestrationInput); + Assert.Equal(updateOptions.OrchestrationInstanceId, config.OrchestrationInstanceId); + Assert.Equal(updateOptions.StartAt, config.StartAt); + Assert.Equal(updateOptions.EndAt, config.EndAt); + Assert.Equal(updateOptions.Interval, config.Interval); + Assert.Equal(updateOptions.StartImmediatelyIfLate, config.StartImmediatelyIfLate); + + Assert.Contains(nameof(ScheduleConfiguration.OrchestrationName), updatedFields); + Assert.Contains(nameof(ScheduleConfiguration.OrchestrationInput), updatedFields); + Assert.Contains(nameof(ScheduleConfiguration.OrchestrationInstanceId), updatedFields); + Assert.Contains(nameof(ScheduleConfiguration.StartAt), updatedFields); + Assert.Contains(nameof(ScheduleConfiguration.EndAt), updatedFields); + Assert.Contains(nameof(ScheduleConfiguration.Interval), updatedFields); + Assert.Contains(nameof(ScheduleConfiguration.StartImmediatelyIfLate), updatedFields); + } + + [Fact] + public void Update_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var config = new ScheduleConfiguration("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)); + + // Act & Assert + var ex = Assert.Throws(() => config.Update(null!)); + Assert.Equal("updateOptions", ex.ParamName); + } +} \ No newline at end of file diff --git a/test/ScheduledTasks.Tests/Models/ScheduleCreationOptionsTests.cs b/test/ScheduledTasks.Tests/Models/ScheduleCreationOptionsTests.cs new file mode 100644 index 00000000..10d21a6b --- /dev/null +++ b/test/ScheduledTasks.Tests/Models/ScheduleCreationOptionsTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.DurableTask.ScheduledTasks.Tests.Models; + +public class ScheduleCreationOptionsTests +{ + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange + string scheduleId = "test-schedule"; + string orchestrationName = "test-orchestration"; + TimeSpan interval = TimeSpan.FromMinutes(5); + + // Act + var options = new ScheduleCreationOptions(scheduleId, orchestrationName, interval); + + // Assert + Assert.Equal(scheduleId, options.ScheduleId); + Assert.Equal(orchestrationName, options.OrchestrationName); + Assert.Equal(interval, options.Interval); + Assert.Null(options.OrchestrationInput); + Assert.Null(options.OrchestrationInstanceId); + Assert.Null(options.StartAt); + Assert.Null(options.EndAt); + Assert.False(options.StartImmediatelyIfLate); + } + + [Theory] + [InlineData(null, "orchestration", typeof(ArgumentNullException), "Value cannot be null")] + [InlineData("", "orchestration", typeof(ArgumentException), "Parameter cannot be an empty string or start with the null character")] + [InlineData("schedule", null, typeof(ArgumentNullException), "Value cannot be null.")] + [InlineData("schedule", "", typeof(ArgumentException), "Parameter cannot be an empty string or start with the null character")] + public void Constructor_WithInvalidParameters_ThrowsArgumentException( + string scheduleId, + string orchestrationName, + Type expectedExceptionType, + string expectedMessage) + { + // Arrange + TimeSpan interval = TimeSpan.FromMinutes(5); + + // Act & Assert + var exception = Assert.Throws(expectedExceptionType, () => new ScheduleCreationOptions(scheduleId, orchestrationName, interval)); + Assert.Contains(expectedMessage, exception.Message); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + public void Constructor_WithNonPositiveInterval_ThrowsArgumentException(int seconds) + { + // Arrange + string scheduleId = "test-schedule"; + string orchestrationName = "test-orchestration"; + TimeSpan interval = TimeSpan.FromSeconds(seconds); + + // Act & Assert + var ex = Assert.Throws(() => new ScheduleCreationOptions(scheduleId, orchestrationName, interval)); + Assert.Contains("Interval must be positive", ex.Message); + } + + [Fact] + public void Constructor_WithIntervalLessThanOneSecond_ThrowsArgumentException() + { + // Arrange + string scheduleId = "test-schedule"; + string orchestrationName = "test-orchestration"; + TimeSpan interval = TimeSpan.FromMilliseconds(500); + + // Act & Assert + var ex = Assert.Throws(() => new ScheduleCreationOptions(scheduleId, orchestrationName, interval)); + Assert.Contains("Interval must be at least 1 second", ex.Message); + } + + [Fact] + public void Properties_SetAndGetCorrectly() + { + // Arrange + var options = new ScheduleCreationOptions("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)); + var now = DateTimeOffset.UtcNow; + + // Act + options = options with + { + OrchestrationInput = "test-input", + OrchestrationInstanceId = "test-instance", + StartAt = now, + EndAt = now.AddDays(1), + StartImmediatelyIfLate = true + }; + + // Assert + Assert.Equal("test-input", options.OrchestrationInput); + Assert.Equal("test-instance", options.OrchestrationInstanceId); + Assert.Equal(now, options.StartAt); + Assert.Equal(now.AddDays(1), options.EndAt); + Assert.True(options.StartImmediatelyIfLate); + } + + [Fact] + public void WithOperator_CreatesNewInstance() + { + // Arrange + var original = new ScheduleCreationOptions("test-schedule", "test-orchestration", TimeSpan.FromMinutes(5)); + var now = DateTimeOffset.UtcNow; + + // Act + var modified = original with + { + OrchestrationInput = "test-input", + StartAt = now + }; + + // Assert + Assert.Equal(original.ScheduleId, modified.ScheduleId); + Assert.Equal(original.OrchestrationName, modified.OrchestrationName); + Assert.Equal(original.Interval, modified.Interval); + Assert.Equal("test-input", modified.OrchestrationInput); + Assert.Equal(now, modified.StartAt); + Assert.NotSame(original, modified); + } +} \ No newline at end of file diff --git a/test/ScheduledTasks.Tests/Models/ScheduleStateTests.cs b/test/ScheduledTasks.Tests/Models/ScheduleStateTests.cs new file mode 100644 index 00000000..ebc3fad7 --- /dev/null +++ b/test/ScheduledTasks.Tests/Models/ScheduleStateTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.DurableTask.ScheduledTasks.Tests.Models; + +public class ScheduleStateTests +{ + [Fact] + public void DefaultConstructor_InitializesCorrectly() + { + // Act + var state = new ScheduleState(); + + // Assert + Assert.Equal(ScheduleStatus.Uninitialized, state.Status); + Assert.NotNull(state.ExecutionToken); + Assert.NotEmpty(state.ExecutionToken); + Assert.Null(state.LastRunAt); + Assert.Null(state.NextRunAt); + Assert.Null(state.ScheduleCreatedAt); + Assert.Null(state.ScheduleLastModifiedAt); + Assert.Null(state.ScheduleConfiguration); + } + + [Fact] + public void RefreshScheduleRunExecutionToken_GeneratesNewToken() + { + // Arrange + var state = new ScheduleState(); + string originalToken = state.ExecutionToken; + + // Act + state.RefreshScheduleRunExecutionToken(); + + // Assert + Assert.NotEqual(originalToken, state.ExecutionToken); + Assert.NotEmpty(state.ExecutionToken); + } + + [Fact] + public void RefreshScheduleRunExecutionToken_GeneratesUniqueTokens() + { + // Arrange + var state = new ScheduleState(); + var tokens = new HashSet(); + + // Act + for (int i = 0; i < 100; i++) + { + state.RefreshScheduleRunExecutionToken(); + tokens.Add(state.ExecutionToken); + } + + // Assert + Assert.Equal(100, tokens.Count); // All tokens should be unique + } + + [Fact] + public void Properties_SetAndGetCorrectly() + { + // Arrange + var state = new ScheduleState(); + var now = DateTimeOffset.UtcNow; + var config = new ScheduleConfiguration("test-id", "test-orchestration", TimeSpan.FromMinutes(5)); + + // Act + state.Status = ScheduleStatus.Active; + state.LastRunAt = now; + state.NextRunAt = now.AddMinutes(5); + state.ScheduleCreatedAt = now; + state.ScheduleLastModifiedAt = now; + state.ScheduleConfiguration = config; + + // Assert + Assert.Equal(ScheduleStatus.Active, state.Status); + Assert.Equal(now, state.LastRunAt); + Assert.Equal(now.AddMinutes(5), state.NextRunAt); + Assert.Equal(now, state.ScheduleCreatedAt); + Assert.Equal(now, state.ScheduleLastModifiedAt); + Assert.Same(config, state.ScheduleConfiguration); + } +} \ No newline at end of file diff --git a/test/ScheduledTasks.Tests/Orchestrations/ExecuteScheduleOperationOrchestratorTests.cs b/test/ScheduledTasks.Tests/Orchestrations/ExecuteScheduleOperationOrchestratorTests.cs new file mode 100644 index 00000000..89b6024d --- /dev/null +++ b/test/ScheduledTasks.Tests/Orchestrations/ExecuteScheduleOperationOrchestratorTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Moq; +using Xunit; + +namespace Microsoft.DurableTask.ScheduledTasks.Tests.Orchestrations; + +public class ExecuteScheduleOperationOrchestratorTests +{ + private readonly Mock mockContext; + private readonly Mock mockEntityClient; + private readonly ExecuteScheduleOperationOrchestrator orchestrator; + + public ExecuteScheduleOperationOrchestratorTests() + { + this.mockContext = new Mock(MockBehavior.Strict); + this.mockEntityClient = new Mock(MockBehavior.Loose); + this.mockContext.Setup(c => c.Entities).Returns(this.mockEntityClient.Object); + this.orchestrator = new ExecuteScheduleOperationOrchestrator(); + } + + [Fact] + public async Task RunAsync_ValidRequest_CallsEntityOperation() + { + // Arrange + var entityId = new EntityInstanceId(nameof(Schedule), "test-schedule"); + var operationName = "TestOperation"; + var input = new { TestData = "test" }; + var expectedResult = new { Result = "success" }; + var request = new ScheduleOperationRequest(entityId, operationName, input); + + this.mockEntityClient + .Setup(e => e.CallEntityAsync(entityId, operationName, input, default)) + .ReturnsAsync(expectedResult); + + // Act + var result = await this.orchestrator.RunAsync(this.mockContext.Object, request); + + // Assert + Assert.Equal(expectedResult, result); + this.mockEntityClient.Verify( + e => e.CallEntityAsync(entityId, operationName, input, default), + Times.Once); + } + + [Fact] + public async Task RunAsync_NullInput_CallsEntityOperation() + { + // Arrange + var entityId = new EntityInstanceId(nameof(Schedule), "test-schedule"); + var operationName = "TestOperation"; + var expectedResult = new { Result = "success" }; + var request = new ScheduleOperationRequest(entityId, operationName); + + this.mockEntityClient + .Setup(e => e.CallEntityAsync(entityId, operationName, null, default)) + .ReturnsAsync(expectedResult); + + // Act + var result = await this.orchestrator.RunAsync(this.mockContext.Object, request); + + // Assert + Assert.Equal(expectedResult, result); + this.mockEntityClient.Verify( + e => e.CallEntityAsync(entityId, operationName, null, default), + Times.Once); + } + + [Fact] + public async Task RunAsync_EntityOperationFails_PropagatesException() + { + // Arrange + var entityId = new EntityInstanceId(nameof(Schedule), "test-schedule"); + var operationName = "TestOperation"; + var request = new ScheduleOperationRequest(entityId, operationName); + var expectedException = new InvalidOperationException("Test exception"); + + this.mockEntityClient + .Setup(e => e.CallEntityAsync(entityId, operationName, null, default)) + .ThrowsAsync(expectedException); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => this.orchestrator.RunAsync(this.mockContext.Object, request)); + Assert.Equal(expectedException.Message, exception.Message); + } +} \ No newline at end of file diff --git a/test/ScheduledTasks.Tests/ScheduledTasks.Tests.csproj b/test/ScheduledTasks.Tests/ScheduledTasks.Tests.csproj new file mode 100644 index 00000000..33726a62 --- /dev/null +++ b/test/ScheduledTasks.Tests/ScheduledTasks.Tests.csproj @@ -0,0 +1,21 @@ + + + + + net6.0 + enable + enable + false + true + + + + + + + + + + + + \ No newline at end of file diff --git a/test/TestHelpers/Logging/TestLogger.cs b/test/TestHelpers/Logging/TestLogger.cs new file mode 100644 index 00000000..adbd3141 --- /dev/null +++ b/test/TestHelpers/Logging/TestLogger.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +public class TestLogger : ILogger +{ + public List<(LogLevel Level, string Message)> Logs { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + string message = formatter(state, exception); + this.Logs.Add((logLevel, message)); + } +} + +public class TestLogger : ILogger +{ + public List<(LogLevel Level, string Message)> Logs { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + string message = formatter(state, exception); + this.Logs.Add((logLevel, message)); + } +} \ No newline at end of file