From ba3ef1dc5db30e775ac456af0a84d9e1f9240d41 Mon Sep 17 00:00:00 2001 From: Neil South Date: Tue, 14 May 2024 10:50:16 +0100 Subject: [PATCH 1/2] adding new dailyStats endpont Signed-off-by: Neil South --- .../Models/ApplicationReviewStatus.cs | 26 ++++ .../Contracts/Models/ExecutionStatDTO.cs | 1 - .../Models/ExecutionStatDayOverview.cs | 39 +++++ .../ITaskExecutionStatsRepository.cs | 11 +- .../TaskExecutionStatsRepository.cs | 26 ++-- .../Controllers/TaskStatsController.cs | 141 +++++++++++++----- 6 files changed, 190 insertions(+), 54 deletions(-) create mode 100644 src/WorkflowManager/Contracts/Models/ApplicationReviewStatus.cs create mode 100644 src/WorkflowManager/Contracts/Models/ExecutionStatDayOverview.cs diff --git a/src/WorkflowManager/Contracts/Models/ApplicationReviewStatus.cs b/src/WorkflowManager/Contracts/Models/ApplicationReviewStatus.cs new file mode 100644 index 000000000..4c6279b6e --- /dev/null +++ b/src/WorkflowManager/Contracts/Models/ApplicationReviewStatus.cs @@ -0,0 +1,26 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Monai.Deploy.WorkflowManager.Common.Contracts.Models +{ + public enum ApplicationReviewStatus + { + Approved, + Rejected, + Cancelled, + AwaitingReview + } +} diff --git a/src/WorkflowManager/Contracts/Models/ExecutionStatDTO.cs b/src/WorkflowManager/Contracts/Models/ExecutionStatDTO.cs index 80316be22..e402b4e49 100644 --- a/src/WorkflowManager/Contracts/Models/ExecutionStatDTO.cs +++ b/src/WorkflowManager/Contracts/Models/ExecutionStatDTO.cs @@ -36,5 +36,4 @@ public ExecutionStatDTO(ExecutionStats stats) public double ExecutionDurationSeconds { get; set; } public string Status { get; set; } = "Created"; } - } diff --git a/src/WorkflowManager/Contracts/Models/ExecutionStatDayOverview.cs b/src/WorkflowManager/Contracts/Models/ExecutionStatDayOverview.cs new file mode 100644 index 000000000..bfc109466 --- /dev/null +++ b/src/WorkflowManager/Contracts/Models/ExecutionStatDayOverview.cs @@ -0,0 +1,39 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using Newtonsoft.Json; + +namespace Monai.Deploy.WorkflowManager.Common.Contracts.Models +{ + public class ExecutionStatDayOverview + { + [JsonProperty("date")] + public DateOnly Date { get; set; } + [JsonProperty("total_executions")] + public int TotalExecutions { get; set; } + [JsonProperty("total_failures")] + public int TotalFailures { get; set; } + [JsonProperty("total_approvals")] + public int TotalApprovals { get; set; } + [JsonProperty("total_rejections")] + public int TotalRejections { get; set; } + [JsonProperty("total_cancelled")] + public int TotalCancelled { get; set; } + [JsonProperty("total_awaiting_review")] + public int TotalAwaitingReview { get; set; } + } +} diff --git a/src/WorkflowManager/Database/Interfaces/ITaskExecutionStatsRepository.cs b/src/WorkflowManager/Database/Interfaces/ITaskExecutionStatsRepository.cs index 998f52eff..c55e87421 100644 --- a/src/WorkflowManager/Database/Interfaces/ITaskExecutionStatsRepository.cs +++ b/src/WorkflowManager/Database/Interfaces/ITaskExecutionStatsRepository.cs @@ -52,6 +52,15 @@ public interface ITaskExecutionStatsRepository /// Task UpdateExecutionStatsAsync(TaskCancellationEvent taskCanceledEvent, string workflowId, string correlationId); + /// + /// Returns all entries between the two given dates + /// + /// start of the range. + /// end of the range. + /// optional workflow id. + /// optional task id. + /// a collections of stats + Task> GetAllStatsAsync(DateTime startTime, DateTime endTime, string workflowId = "", string taskId = ""); /// /// Returns paged entries between the two given dates /// @@ -62,7 +71,7 @@ public interface ITaskExecutionStatsRepository /// optional workflow id. /// optional task id. /// a collections of stats - Task> GetStatsAsync(DateTime startTime, DateTime endTime, int pageSize = 10, int pageNumber = 1, string workflowId = "", string taskId = ""); + Task> GetStatsAsync(DateTime startTime, DateTime endTime, int? pageSize = 10, int? pageNumber = 1, string workflowId = "", string taskId = ""); /// /// Return the count of the entries with this status, or all if no status given. diff --git a/src/WorkflowManager/Database/Repositories/TaskExecutionStatsRepository.cs b/src/WorkflowManager/Database/Repositories/TaskExecutionStatsRepository.cs index d1b372f5b..777411797 100644 --- a/src/WorkflowManager/Database/Repositories/TaskExecutionStatsRepository.cs +++ b/src/WorkflowManager/Database/Repositories/TaskExecutionStatsRepository.cs @@ -19,7 +19,6 @@ using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; -using Ardalis.GuardClauses; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Monai.Deploy.Messaging.Events; @@ -40,11 +39,7 @@ public TaskExecutionStatsRepository( IOptions databaseSettings, ILogger logger) { - if (client == null) - { - throw new ArgumentNullException(nameof(client)); - } - + _ = client ?? throw new ArgumentNullException(nameof(client)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); var mongoDatabase = client.GetDatabase(databaseSettings.Value.DatabaseName, null); _taskExecutionStatsCollection = mongoDatabase.GetCollection("ExecutionStats", null); @@ -149,17 +144,24 @@ await _taskExecutionStatsCollection.UpdateOneAsync(o => } } - public async Task> GetStatsAsync(DateTime startTime, DateTime endTime, int pageSize = 10, int pageNumber = 1, string workflowId = "", string taskId = "") + public async Task> GetAllStatsAsync(DateTime startTime, DateTime endTime, string workflowId = "", string taskId = "") + { + return await GetStatsAsync(startTime, endTime, null, null, workflowId, taskId); + } + + public async Task> GetStatsAsync(DateTime startTime, DateTime endTime, int? pageSize = 10, int? pageNumber = 1, string workflowId = "", string taskId = "") { CreateFilter(startTime, endTime, workflowId, taskId, out var builder, out var filter); filter &= builder.Where(GetExecutedTasksFilter()); - var result = await _taskExecutionStatsCollection.Find(filter) - .Limit(pageSize) - .Skip((pageNumber - 1) * pageSize) - .ToListAsync(); - return result; + var result = _taskExecutionStatsCollection.Find(filter); + if (pageSize is not null) + { + result = result.Limit(pageSize).Skip((pageNumber - 1) * pageSize); + } + + return await result.ToListAsync(); } private static ExecutionStats ExposeExecutionStats(ExecutionStats taskExecutionStats, TaskExecution taskUpdateEvent) diff --git a/src/WorkflowManager/WorkflowManager/Controllers/TaskStatsController.cs b/src/WorkflowManager/WorkflowManager/Controllers/TaskStatsController.cs index 7a4fa2bc4..b96823f07 100644 --- a/src/WorkflowManager/WorkflowManager/Controllers/TaskStatsController.cs +++ b/src/WorkflowManager/WorkflowManager/Controllers/TaskStatsController.cs @@ -113,6 +113,59 @@ public async Task GetOverviewAsync([FromQuery] DateTime startTime } } + /// + /// Get execution daily stats for a given time period. + /// + /// TimeFiler defining start and end times, plus paging options. + /// WorkflowId if you want stats just for a given workflow. (both workflowId and TaskId must be given, if you give one). + /// a paged obect with all the stat details. + [ProducesResponseType(typeof(StatsPagedResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + [HttpGet("dailystats")] + public async Task GetDailyStatsAsync([FromQuery] TimeFilter filter, string workflowId = "") + { + SetUpFilter(filter, out var route, out var pageSize, out var validFilter); + + try + { + var allStats = await _repository.GetAllStatsAsync(filter.StartTime, filter.EndTime, workflowId, string.Empty); + var statsDto = allStats + .OrderBy(a => a.StartedUTC) + .GroupBy(s => s.StartedUTC.Date) + .Select(g => new ExecutionStatDayOverview + { + Date = DateOnly.FromDateTime(g.Key.Date), + TotalExecutions = g.Count(), + TotalFailures = g.Count(i => string.Compare(i.Status, "Failed", true) == 0), + TotalApprovals = g.Count(i => string.Compare(i.Status, ApplicationReviewStatus.Approved.ToString(), true) == 0), + TotalRejections = g.Count(i => string.Compare(i.Status, ApplicationReviewStatus.Rejected.ToString(), true) == 0), + TotalCancelled = g.Count(i => string.Compare(i.Status, ApplicationReviewStatus.Cancelled.ToString(), true) == 0), + TotalAwaitingReview = g.Count(i => string.Compare(i.Status, ApplicationReviewStatus.AwaitingReview.ToString(), true) == 0), + }); + + var pagedStats = statsDto.Skip((filter.PageNumber - 1) * pageSize).Take(pageSize); + + var res = CreateStatsPagedResponse(pagedStats, validFilter, statsDto.Count(), _uriService, route); + var (avgTotalExecution, avgArgoExecution) = await _repository.GetAverageStats(filter.StartTime, filter.EndTime, workflowId, string.Empty); + + res.PeriodStart = filter.StartTime; + res.PeriodEnd = filter.EndTime; + res.TotalExecutions = allStats.Count(); + res.TotalSucceeded = statsDto.Sum(s => s.TotalApprovals); + res.TotalFailures = statsDto.Sum(s => s.TotalFailures); + res.TotalInprogress = statsDto.Sum(s => s.TotalAwaitingReview); + res.AverageTotalExecutionSeconds = Math.Round(avgTotalExecution, 2); + res.AverageArgoExecutionSeconds = Math.Round(avgArgoExecution, 2); + + return Ok(res); + } + catch (Exception e) + { + _logger.GetStatsAsyncError(e); + return Problem($"Unexpected error occurred: {e.Message}", $"tasks/stats", InternalServerError); + } + } + /// /// Get execution stats for a given time period. /// @@ -133,63 +186,71 @@ public async Task GetStatsAsync([FromQuery] TimeFilter filter, st return Problem("Failed to validate ids, not a valid guid", "tasks/stats/", BadRequest); } - if (filter.EndTime == default) - { - filter.EndTime = DateTime.Now; - } + SetUpFilter(filter, out var route, out var pageSize, out var validFilter); - if (filter.StartTime == default) + try { - filter.StartTime = new DateTime(2023, 1, 1); - } - - var route = Request?.Path.Value ?? string.Empty; - var pageSize = filter.PageSize ?? Options.Value.EndpointSettings?.DefaultPageSize ?? 10; - var max = Options.Value.EndpointSettings?.MaxPageSize ?? 20; - var validFilter = new PaginationFilter(filter.PageNumber, pageSize, max); + var allStats = await _repository.GetStatsAsync(filter.StartTime, filter.EndTime, pageSize, filter.PageNumber, workflowId, taskId); + var statsDto = allStats + .OrderBy(a => a.StartedUTC) + .Select(s => new ExecutionStatDTO(s)); - try + var res = await GatherPagedStats(filter, workflowId, taskId, route, validFilter, statsDto); + return Ok(res); + } + catch (Exception e) { - workflowId ??= string.Empty; - taskId ??= string.Empty; - var allStats = _repository.GetStatsAsync(filter.StartTime, filter.EndTime, pageSize, filter.PageNumber, workflowId, taskId); + _logger.GetStatsAsyncError(e); + return Problem($"Unexpected error occurred: {e.Message}", $"tasks/stats", InternalServerError); + } + } - var successes = _repository.GetStatsStatusSucceededCountAsync(filter.StartTime, filter.EndTime, workflowId, taskId); + private async Task>> GatherPagedStats(TimeFilter filter, string workflowId, string taskId, string route, PaginationFilter validFilter, IEnumerable statsDto) + { + workflowId ??= string.Empty; + taskId ??= string.Empty; - var fails = _repository.GetStatsStatusFailedCountAsync(filter.StartTime, filter.EndTime, workflowId, taskId); + var successes = _repository.GetStatsStatusSucceededCountAsync(filter.StartTime, filter.EndTime, workflowId, taskId); - var rangeCount = _repository.GetStatsTotalCompleteExecutionsCountAsync(filter.StartTime, filter.EndTime, workflowId, taskId); + var fails = _repository.GetStatsStatusFailedCountAsync(filter.StartTime, filter.EndTime, workflowId, taskId); - var stats = _repository.GetAverageStats(filter.StartTime, filter.EndTime, workflowId, taskId); + var rangeCount = _repository.GetStatsTotalCompleteExecutionsCountAsync(filter.StartTime, filter.EndTime, workflowId, taskId); - var running = _repository.GetStatsStatusCountAsync(filter.StartTime, filter.EndTime, TaskExecutionStatus.Accepted.ToString(), workflowId, taskId); + var stats = _repository.GetAverageStats(filter.StartTime, filter.EndTime, workflowId, taskId); - await Task.WhenAll(allStats, fails, rangeCount, stats, running); + var running = _repository.GetStatsStatusCountAsync(filter.StartTime, filter.EndTime, TaskExecutionStatus.Accepted.ToString(), workflowId, taskId); - ExecutionStatDTO[] statsDto; + await Task.WhenAll(fails, rangeCount, stats, running); - statsDto = allStats.Result - .OrderBy(a => a.StartedUTC) - .Select(s => new ExecutionStatDTO(s)) - .ToArray(); + var res = CreateStatsPagedResponse(statsDto, validFilter, rangeCount.Result, _uriService, route); - var res = CreateStatsPagedResponse(statsDto, validFilter, rangeCount.Result, _uriService, route); + res.PeriodStart = filter.StartTime; + res.PeriodEnd = filter.EndTime; + res.TotalExecutions = rangeCount.Result; + res.TotalSucceeded = successes.Result; + res.TotalFailures = fails.Result; + res.TotalInprogress = running.Result; + res.AverageTotalExecutionSeconds = Math.Round(stats.Result.avgTotalExecution, 2); + res.AverageArgoExecutionSeconds = Math.Round(stats.Result.avgArgoExecution, 2); + return res; + } - res.PeriodStart = filter.StartTime; - res.PeriodEnd = filter.EndTime; - res.TotalExecutions = rangeCount.Result; - res.TotalSucceeded = successes.Result; - res.TotalFailures = fails.Result; - res.TotalInprogress = running.Result; - res.AverageTotalExecutionSeconds = Math.Round(stats.Result.avgTotalExecution, 2); - res.AverageArgoExecutionSeconds = Math.Round(stats.Result.avgArgoExecution, 2); - return Ok(res); + private void SetUpFilter(TimeFilter filter, out string route, out int pageSize, out PaginationFilter validFilter) + { + if (filter.EndTime == default) + { + filter.EndTime = DateTime.Now; } - catch (Exception e) + + if (filter.StartTime == default) { - _logger.GetStatsAsyncError(e); - return Problem($"Unexpected error occurred: {e.Message}", $"tasks/stats", InternalServerError); + filter.StartTime = new DateTime(2023, 1, 1); } + + route = Request?.Path.Value ?? string.Empty; + pageSize = filter.PageSize ?? Options.Value.EndpointSettings?.DefaultPageSize ?? 10; + var max = Options.Value.EndpointSettings?.MaxPageSize ?? 20; + validFilter = new PaginationFilter(filter.PageNumber, pageSize, max); } } } From 44697c0e81adcdf74cd8058dc5a5857d792af1b9 Mon Sep 17 00:00:00 2001 From: Neil South Date: Tue, 14 May 2024 11:22:04 +0100 Subject: [PATCH 2/2] adding some tests Signed-off-by: Neil South --- .../TaskExecutionStatsControllerTests.cs | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/tests/UnitTests/WorkflowManager.Tests/Controllers/TaskExecutionStatsControllerTests.cs b/tests/UnitTests/WorkflowManager.Tests/Controllers/TaskExecutionStatsControllerTests.cs index 3a6f1ca50..b6a8c4e7f 100644 --- a/tests/UnitTests/WorkflowManager.Tests/Controllers/TaskExecutionStatsControllerTests.cs +++ b/tests/UnitTests/WorkflowManager.Tests/Controllers/TaskExecutionStatsControllerTests.cs @@ -44,6 +44,9 @@ public class ExecutionStatsControllerTests private readonly Mock _uriService; private readonly IOptions _options; private readonly ExecutionStats[] _executionStats; + private readonly DateTime _startTime; + + #pragma warning disable CS8602 // Dereference of a possibly null reference. #pragma warning disable CS8604 // Possible null reference argument. #pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. @@ -55,18 +58,19 @@ public ExecutionStatsControllerTests() _uriService = new Mock(); StatsController = new TaskStatsController(_options, _uriService.Object, _logger.Object, _repo.Object); - var startTime = new DateTime(2023, 4, 4); + _startTime = new DateTime(2023, 4, 4); _executionStats = new ExecutionStats[] { new ExecutionStats { ExecutionId = Guid.NewGuid().ToString(), - StartedUTC = startTime, + StartedUTC = _startTime, WorkflowInstanceId= "workflow", TaskId = "task", }, }; _repo.Setup(w => w.GetStatsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(_executionStats); + _repo.Setup(w => w.GetAllStatsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(_executionStats); _repo.Setup(w => w.GetStatsStatusCountAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(_executionStats.Count()); _repo.Setup(w => w.GetStatsTotalCompleteExecutionsCountAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(_executionStats.Count()); } @@ -277,6 +281,61 @@ public async Task GetStatsAsync_Only_Find_Matching_Results() var pagegedResults = objectResult.Value as StatsPagedResponse>; Assert.Equal(1, pagegedResults.TotalRecords); } + + [Fact] + public async Task GetDailyStatsAsync_ReturnsList() + { + _uriService.Setup(s => s.GetPageUriString(It.IsAny(), It.IsAny())).Returns(() => "unitTest"); + + var result = await StatsController.GetDailyStatsAsync(new TimeFilter(), ""); + + var objectResult = Assert.IsType(result); + + var responseValue = (StatsPagedResponse>)objectResult.Value; + responseValue.Data.First().Date.Should().Be(DateOnly.FromDateTime( _startTime)); + responseValue.FirstPage.Should().Be("unitTest"); + responseValue.LastPage.Should().Be("unitTest"); + responseValue.PageNumber.Should().Be(1); + responseValue.PageSize.Should().Be(10); + responseValue.TotalPages.Should().Be(1); + responseValue.TotalRecords.Should().Be(1); + responseValue.Succeeded.Should().Be(true); + responseValue.PreviousPage.Should().Be(null); + responseValue.NextPage.Should().Be(null); + responseValue.Errors.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task GetAllStatsAsync_ServiceException_ReturnProblem() + { + _repo.Setup(w => w.GetAllStatsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new Exception()); + + var result = await StatsController.GetDailyStatsAsync(new TimeFilter(), ""); + + var objectResult = Assert.IsType(result); + Assert.Equal((int)HttpStatusCode.InternalServerError, objectResult.StatusCode); + + const string expectedInstance = "tasks/stats"; + Assert.StartsWith(expectedInstance, ((ProblemDetails)objectResult.Value).Instance); + } + + [Fact] + public async Task GetAllStatsAsync_Pass_All_Arguments_To_GetStatsAsync_In_Repo() + { + var startTime = new DateTime(2023, 4, 4); + var endTime = new DateTime(2023, 4, 5); + const int pageNumber = 15; + const int pageSize = 9; + + var result = await StatsController.GetDailyStatsAsync(new TimeFilter { StartTime = startTime, EndTime = endTime, PageNumber = pageNumber, PageSize = pageSize }, "workflow"); + + _repo.Verify(v => v.GetAllStatsAsync( + It.Is(d => d.Equals(startTime)), + It.Is(d => d.Equals(endTime)), + It.Is(s => s.Equals("workflow")), + It.Is(s => s.Equals(""))) + ); + } } #pragma warning restore CS8604 // Possible null reference argument. #pragma warning restore CS8602 // Dereference of a possibly null reference.