diff --git a/Sunrise.API/Controllers/ScoreProcessingController.cs b/Sunrise.API/Controllers/ScoreProcessingController.cs new file mode 100644 index 00000000..b440c758 --- /dev/null +++ b/Sunrise.API/Controllers/ScoreProcessingController.cs @@ -0,0 +1,132 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Sunrise.API.Attributes; +using Sunrise.API.Extensions; +using Sunrise.API.Serializable.Request; +using Sunrise.API.Serializable.Response; +using Sunrise.API.Services; +using Sunrise.Shared.Attributes; +using Sunrise.Shared.Enums.Scores; + +namespace Sunrise.API.Controllers; + +[ApiController] +[ApiHttpTrace] +[Route("score-processing")] +[Subdomain("api")] +[Authorize("RequireSuperUser")] +[ProducesResponseType(StatusCodes.Status200OK)] +[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status400BadRequest)] +[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status401Unauthorized)] +[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status403Forbidden)] +public class ScoreProcessingController(ScoreProcessingService scoreProcessingService) : ControllerBase +{ + [HttpGet("")] + [EndpointDescription("List score processing tasks (filterable, paginated)")] + [ProducesResponseType(typeof(ScoreProcessingTasksResponse), StatusCodes.Status200OK)] + public async Task GetTasks( + [Range(1, int.MaxValue)] [FromQuery(Name = "page")] + int page = 1, + [Range(1, 100)] [FromQuery(Name = "limit")] + int limit = 25, + [FromQuery(Name = "status")] ScoreProcessingStatus? status = null, + [FromQuery(Name = "task_type")] ScoreTaskType? taskType = null, + [FromQuery(Name = "score_id")] int? scoreId = null, + [FromQuery(Name = "task_id")] int? taskId = null, + CancellationToken ct = default) + { + return await scoreProcessingService.GetTasks(page, limit, status, taskType, scoreId, taskId, ct); + } + + [HttpGet("stats")] + [EndpointDescription("Score processing queue stats (pending/processing/failed counts + raw ETA)")] + [ProducesResponseType(typeof(ScoreProcessingStatsResponse), StatusCodes.Status200OK)] + public async Task GetStats(CancellationToken ct = default) + { + return await scoreProcessingService.GetStats(ct); + } + + [HttpGet("{id:int}")] + [EndpointDescription("Get a single score processing task")] + [ProducesResponseType(typeof(ScoreProcessingTaskResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)] + public async Task GetTask([Range(1, int.MaxValue)] int id, CancellationToken ct = default) + { + return await scoreProcessingService.GetTask(id, ct); + } + + [HttpGet("score/{scoreId:int}")] + [EndpointDescription("Preview a score and its active processing task")] + [ProducesResponseType(typeof(ScoreProcessingPreviewResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)] + public async Task GetPreview([Range(1, int.MaxValue)] int scoreId, CancellationToken ct = default) + { + return await scoreProcessingService.GetPreview(scoreId, ct); + } + + [HttpPost("")] + [EndpointDescription("Queue a recalculate/restore/delete action for a score")] + [ProducesResponseType(typeof(ScoreProcessingTaskResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status409Conflict)] + public async Task CreateTask([FromBody] CreateScoreProcessingTaskRequest request, CancellationToken ct = default) + { + var executorId = HttpContext.GetCurrentUserOrThrow().Id; + return await scoreProcessingService.CreateTask(executorId, request.ScoreId, request.Action, ct); + } + + [HttpPost("{id:int}/cancel")] + [EndpointDescription("Stop a pending score processing task")] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status409Conflict)] + public async Task CancelTask([Range(1, int.MaxValue)] int id, CancellationToken ct = default) + { + var executorId = HttpContext.GetCurrentUserOrThrow().Id; + return await scoreProcessingService.CancelTask(executorId, id, ct); + } + + [HttpPost("{id:int}/requeue")] + [EndpointDescription("Requeue a failed score processing task")] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status409Conflict)] + public async Task RequeueTask([Range(1, int.MaxValue)] int id, CancellationToken ct = default) + { + var executorId = HttpContext.GetCurrentUserOrThrow().Id; + return await scoreProcessingService.Requeue(executorId, id, ct); + } + + [HttpPost("bulk")] + [EndpointDescription("Queue an action for an explicit list of score ids (max 100)")] + [ProducesResponseType(typeof(BulkScoreProcessingResultResponse), StatusCodes.Status200OK)] + public async Task BulkByIds([FromBody] BulkScoreProcessingRequest request, CancellationToken ct = default) + { + var executorId = HttpContext.GetCurrentUserOrThrow().Id; + return await scoreProcessingService.BulkByIds(executorId, request.ScoreIds, request.Action, ct); + } + + [HttpPost("bulk-by-filter")] + [EndpointDescription("Queue an action for every score matching the filter for a user (runs in background)")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult BulkByFilter([FromBody] BulkScoreProcessingByFilterRequest request) + { + var executorId = HttpContext.GetCurrentUserOrThrow().Id; + return scoreProcessingService.BulkByFilter(executorId, request); + } + + [HttpGet("events")] + [EndpointDescription("List score processing audit events (filterable, paginated)")] + [ProducesResponseType(typeof(EventScoreProcessingListResponse), StatusCodes.Status200OK)] + public async Task GetEvents( + [Range(1, int.MaxValue)] [FromQuery(Name = "page")] + int page = 1, + [Range(1, 100)] [FromQuery(Name = "limit")] + int limit = 25, + [FromQuery(Name = "types")] List? types = null, + [FromQuery(Name = "score_id")] int? scoreId = null, + CancellationToken ct = default) + { + return await scoreProcessingService.GetEvents(page, limit, types, scoreId, ct); + } +} \ No newline at end of file diff --git a/Sunrise.API/Controllers/UserController.cs b/Sunrise.API/Controllers/UserController.cs index ee9cd87d..cf6c317e 100644 --- a/Sunrise.API/Controllers/UserController.cs +++ b/Sunrise.API/Controllers/UserController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using osu.Shared; using Sunrise.API.Attributes; using Sunrise.API.Extensions; using Sunrise.API.Objects.Keys; @@ -18,13 +19,16 @@ using Sunrise.Shared.Database.Objects; using Sunrise.Shared.Enums; using Sunrise.Shared.Enums.Leaderboards; +using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Enums.Users; using Sunrise.Shared.Objects; using Sunrise.Shared.Objects.Keys; using Sunrise.Shared.Objects.Serializable.Events; using Sunrise.Shared.Repositories; using Sunrise.Shared.Services; +using BeatmapStatus = Sunrise.Shared.Enums.Beatmaps.BeatmapStatus; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; namespace Sunrise.API.Controllers; @@ -383,6 +387,56 @@ public async Task GetUserScores( return Ok(new ScoresResponse(parsedScores, totalScores)); } + [HttpGet] + [Authorize("RequireSuperUser")] + [Route("{id:int}/scores/admin")] + [EndpointDescription("Admin: list all raw user scores (no dedup, includes deleted/failed)")] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(AdminScoresResponse), StatusCodes.Status200OK)] + public async Task GetUserScoresAdmin( + [Range(1, int.MaxValue)] int id, + [FromQuery(Name = "mode")] GameMode? mode = null, + [FromQuery(Name = "mods")] IEnumerable? mods = null, + [FromQuery(Name = "submission_status")] + SubmissionStatus? submissionStatus = null, + [FromQuery(Name = "beatmap_status")] BeatmapStatus? beatmapStatus = null, + [FromQuery(Name = "submitted_from")] DateTime? submittedFrom = null, + [FromQuery(Name = "submitted_to")] DateTime? submittedTo = null, + [FromQuery(Name = "sort")] ScoreSortType sort = ScoreSortType.Date, + [Range(1, 100)] [FromQuery(Name = "limit")] + int limit = 25, + [Range(1, int.MaxValue)] [FromQuery(Name = "page")] + int page = 1, + CancellationToken ct = default) + { + var user = await database.Users.GetUser(id, options: new QueryOptions(true), ct: ct); + if (user == null) + return Problem(ApiErrorResponse.Detail.UserNotFound, statusCode: StatusCodes.Status404NotFound); + + var modsEnum = (mods ?? Array.Empty()).Aggregate(Mods.None, (current, mod) => current | mod); + + var (scores, totalCount) = await database.Scores.GetScores( + mode, + new QueryOptions(true, new Pagination(page, limit)) + { + QueryModifier = query => query.Cast().IncludeUser() + }, + null, + user.Id, + mods != null ? modsEnum : null, + submissionStatus, + beatmapStatus, + submittedFrom, + submittedTo, + sort, + false, + ct); + + var parsedScores = scores.Select(score => new AdminScoreResponse(sessions, score)).ToList(); + + return Ok(new AdminScoresResponse(parsedScores, totalCount)); + } + [HttpGet] [Route("{id:int}/mostplayed")] [EndpointDescription("Get user most played beatmaps")] diff --git a/Sunrise.API/Extensions/ProblemResultExtensions.cs b/Sunrise.API/Extensions/ProblemResultExtensions.cs new file mode 100644 index 00000000..675479a3 --- /dev/null +++ b/Sunrise.API/Extensions/ProblemResultExtensions.cs @@ -0,0 +1,20 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; + +namespace Sunrise.API.Extensions; + +public static class ProblemResultExtensions +{ + public static IActionResult ToProblemResult(this string detail, HttpStatusCode statusCode, string? title = null) + { + return new ObjectResult(new ProblemDetails + { + Title = title, + Detail = detail, + Status = (int)statusCode + }) + { + StatusCode = (int)statusCode + }; + } +} diff --git a/Sunrise.API/Objects/Keys/ApiErrorResponse.cs b/Sunrise.API/Objects/Keys/ApiErrorResponse.cs index f6f853d0..f629e132 100644 --- a/Sunrise.API/Objects/Keys/ApiErrorResponse.cs +++ b/Sunrise.API/Objects/Keys/ApiErrorResponse.cs @@ -15,6 +15,10 @@ public static class Title public const string UnableToRegisterUser = "Unable to register user."; public const string UnableToAuthenticate = "Unable to authenticate."; public const string UnableToRefreshAuthToken = "Unable to refresh auth token."; + + public const string UnableToQueueScoreProcessing = "Unable to queue score processing."; + public const string UnableToCancelScoreTask = "Unable to cancel score task."; + public const string UnableToRequeueScoreTask = "Unable to requeue score task."; } public static class Detail @@ -35,6 +39,11 @@ public static class Detail public const string ReplayNotFound = "Replay not found."; public const string BeatmapNotFound = "Beatmap not found."; + public const string ScoreAlreadyQueued = "Score already has an active queued task."; + public const string ScoreTaskNotFound = "Score processing task not found."; + public const string InvalidScoreProcessingAction = "Invalid score processing action. Allowed actions are recalculation, restore and delete."; + public const string TooManyScoreIds = "Too many score ids provided in a single bulk request."; + public const string InsufficientPrivileges = "Insufficient privileges to perform this action."; public const string RestrictionReasonMustBeProvided = "Restriction reason must be provided when restricting a user."; diff --git a/Sunrise.API/Serializable/Request/BulkScoreProcessingByFilterRequest.cs b/Sunrise.API/Serializable/Request/BulkScoreProcessingByFilterRequest.cs new file mode 100644 index 00000000..39a214e6 --- /dev/null +++ b/Sunrise.API/Serializable/Request/BulkScoreProcessingByFilterRequest.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using osu.Shared; +using Sunrise.Shared.Enums.Scores; +using BeatmapStatus = Sunrise.Shared.Enums.Beatmaps.BeatmapStatus; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.API.Serializable.Request; + +public class BulkScoreProcessingByFilterRequest +{ + [JsonPropertyName("action")] + [Required] + public ScoreTaskType Action { get; set; } + + [JsonPropertyName("user_id")] + [Required] + [Range(1, int.MaxValue)] + public int UserId { get; set; } + + [JsonPropertyName("mode")] + public GameMode? Mode { get; set; } + + [JsonPropertyName("mods")] + public IEnumerable? Mods { get; set; } + + [JsonPropertyName("submission_status")] + public SubmissionStatus? SubmissionStatus { get; set; } + + [JsonPropertyName("beatmap_status")] + public BeatmapStatus? BeatmapStatus { get; set; } + + [JsonPropertyName("submitted_from")] + public DateTime? SubmittedFrom { get; set; } + + [JsonPropertyName("submitted_to")] + public DateTime? SubmittedTo { get; set; } +} \ No newline at end of file diff --git a/Sunrise.API/Serializable/Request/BulkScoreProcessingRequest.cs b/Sunrise.API/Serializable/Request/BulkScoreProcessingRequest.cs new file mode 100644 index 00000000..d7195e64 --- /dev/null +++ b/Sunrise.API/Serializable/Request/BulkScoreProcessingRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Sunrise.Shared.Enums.Scores; + +namespace Sunrise.API.Serializable.Request; + +public class BulkScoreProcessingRequest +{ + [JsonPropertyName("score_ids")] + [Required] + [MinLength(1)] + public List ScoreIds { get; set; } = []; + + [JsonPropertyName("action")] + [Required] + public ScoreTaskType Action { get; set; } +} diff --git a/Sunrise.API/Serializable/Request/CreateScoreProcessingTaskRequest.cs b/Sunrise.API/Serializable/Request/CreateScoreProcessingTaskRequest.cs new file mode 100644 index 00000000..bd1c3fa5 --- /dev/null +++ b/Sunrise.API/Serializable/Request/CreateScoreProcessingTaskRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Sunrise.Shared.Enums.Scores; + +namespace Sunrise.API.Serializable.Request; + +public class CreateScoreProcessingTaskRequest +{ + [JsonPropertyName("score_id")] + [Required] + [Range(1, int.MaxValue)] + public int ScoreId { get; set; } + + [JsonPropertyName("action")] + [Required] + public ScoreTaskType Action { get; set; } +} diff --git a/Sunrise.API/Serializable/Response/AdminScoreResponse.cs b/Sunrise.API/Serializable/Response/AdminScoreResponse.cs new file mode 100644 index 00000000..b1375e0e --- /dev/null +++ b/Sunrise.API/Serializable/Response/AdminScoreResponse.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Repositories; + +namespace Sunrise.API.Serializable.Response; + +public class AdminScoreResponse +{ + [JsonConstructor] + public AdminScoreResponse() + { + } + + public AdminScoreResponse(SessionRepository sessionRepository, Score score) + { + Score = new ScoreResponse(sessionRepository, score); + SubmissionStatus = score.SubmissionStatus; + BeatmapStatus = score.BeatmapStatus; + IsScoreable = score.IsScoreable; + ScoreHash = score.ScoreHash; + } + + [JsonPropertyName("score")] + public ScoreResponse Score { get; set; } + + [JsonPropertyName("submission_status")] + public SubmissionStatus SubmissionStatus { get; set; } + + [JsonPropertyName("beatmap_status")] + public BeatmapStatus BeatmapStatus { get; set; } + + [JsonPropertyName("is_scoreable")] + public bool IsScoreable { get; set; } + + [JsonPropertyName("score_hash")] + public string? ScoreHash { get; set; } +} diff --git a/Sunrise.API/Serializable/Response/AdminScoresResponse.cs b/Sunrise.API/Serializable/Response/AdminScoresResponse.cs new file mode 100644 index 00000000..c74d178f --- /dev/null +++ b/Sunrise.API/Serializable/Response/AdminScoresResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Sunrise.API.Serializable.Response; + +public class AdminScoresResponse(List scores, int totalCount) +{ + [JsonPropertyName("scores")] + public List Scores { get; set; } = scores; + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } = totalCount; +} diff --git a/Sunrise.API/Serializable/Response/BeatmapSetResponse.cs b/Sunrise.API/Serializable/Response/BeatmapSetResponse.cs index ca064101..c8b2d109 100644 --- a/Sunrise.API/Serializable/Response/BeatmapSetResponse.cs +++ b/Sunrise.API/Serializable/Response/BeatmapSetResponse.cs @@ -17,7 +17,7 @@ public BeatmapSetResponse(SessionRepository sessions, BeatmapSet beatmapSet) CreatorId = beatmapSet.UserId; Status = beatmapSet.StatusGeneric; LastUpdated = beatmapSet.LastUpdated; - SubmittedDate = beatmapSet.SubmittedDate; + SubmittedDate = beatmapSet.SubmittedDate ?? DateTime.MinValue; RankedDate = beatmapSet.RankedDate; HasVideo = beatmapSet.HasVideo; Beatmaps = beatmapSet.Beatmaps.Select(beatmap => new BeatmapResponse(sessions, beatmap, beatmapSet)).ToList(); @@ -58,9 +58,8 @@ public BeatmapSetResponse() public DateTime LastUpdated { get; set; } [JsonPropertyName("submitted_date")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonConverter(typeof(DateTimeWithTimezoneConverter))] - public DateTime? SubmittedDate { get; set; } + public DateTime SubmittedDate { get; set; } [JsonPropertyName("ranked_date")] [JsonConverter(typeof(DateTimeWithTimezoneConverter))] diff --git a/Sunrise.API/Serializable/Response/BulkScoreProcessingResultResponse.cs b/Sunrise.API/Serializable/Response/BulkScoreProcessingResultResponse.cs new file mode 100644 index 00000000..e1bb6f8d --- /dev/null +++ b/Sunrise.API/Serializable/Response/BulkScoreProcessingResultResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Sunrise.API.Serializable.Response; + +public class BulkScoreProcessingResultResponse(int queued, int skipped) +{ + [JsonPropertyName("queued")] + public int Queued { get; set; } = queued; + + [JsonPropertyName("skipped")] + public int Skipped { get; set; } = skipped; +} diff --git a/Sunrise.API/Serializable/Response/EventScoreProcessingListResponse.cs b/Sunrise.API/Serializable/Response/EventScoreProcessingListResponse.cs new file mode 100644 index 00000000..827fdbc3 --- /dev/null +++ b/Sunrise.API/Serializable/Response/EventScoreProcessingListResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Sunrise.API.Serializable.Response; + +public class EventScoreProcessingListResponse(List events, int totalCount) +{ + [JsonPropertyName("events")] + public List Events { get; set; } = events; + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } = totalCount; +} diff --git a/Sunrise.API/Serializable/Response/EventScoreProcessingResponse.cs b/Sunrise.API/Serializable/Response/EventScoreProcessingResponse.cs new file mode 100644 index 00000000..ea1a6a0f --- /dev/null +++ b/Sunrise.API/Serializable/Response/EventScoreProcessingResponse.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using Sunrise.Shared.Database.Models.Events; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Repositories; + +namespace Sunrise.API.Serializable.Response; + +public class EventScoreProcessingResponse +{ + [JsonConstructor] + public EventScoreProcessingResponse() + { + } + + public EventScoreProcessingResponse(SessionRepository sessionRepository, EventScoreProcessing scoreProcessingEvent) + { + Id = scoreProcessingEvent.Id; + EventType = scoreProcessingEvent.EventType; + Executor = scoreProcessingEvent.Executor != null ? new UserResponse(sessionRepository, scoreProcessingEvent.Executor) : null; + ScoreId = scoreProcessingEvent.ScoreId; + TaskId = scoreProcessingEvent.TaskId; + JsonData = scoreProcessingEvent.JsonData; + Time = scoreProcessingEvent.Time; + } + + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("event_type")] + public ScoreProcessingEventType EventType { get; set; } + + [JsonPropertyName("executor")] + public UserResponse? Executor { get; set; } + + [JsonPropertyName("score_id")] + public int? ScoreId { get; set; } + + [JsonPropertyName("task_id")] + public int? TaskId { get; set; } + + [JsonPropertyName("json_data")] + public string? JsonData { get; set; } + + [JsonPropertyName("created_at")] + public DateTime Time { get; set; } +} \ No newline at end of file diff --git a/Sunrise.API/Serializable/Response/ScoreProcessingPreviewResponse.cs b/Sunrise.API/Serializable/Response/ScoreProcessingPreviewResponse.cs new file mode 100644 index 00000000..f4ea3e81 --- /dev/null +++ b/Sunrise.API/Serializable/Response/ScoreProcessingPreviewResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Sunrise.API.Serializable.Response; + +public class ScoreProcessingPreviewResponse +{ + [JsonConstructor] + public ScoreProcessingPreviewResponse() + { + } + + public ScoreProcessingPreviewResponse(AdminScoreResponse score, ScoreProcessingTaskResponse? activeTask) + { + Score = score; + ActiveTask = activeTask; + } + + [JsonPropertyName("score")] + public AdminScoreResponse Score { get; set; } + + [JsonPropertyName("active_task")] + public ScoreProcessingTaskResponse? ActiveTask { get; set; } +} diff --git a/Sunrise.API/Serializable/Response/ScoreProcessingStatsResponse.cs b/Sunrise.API/Serializable/Response/ScoreProcessingStatsResponse.cs new file mode 100644 index 00000000..66abe828 --- /dev/null +++ b/Sunrise.API/Serializable/Response/ScoreProcessingStatsResponse.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Sunrise.API.Serializable.Response; + +public class ScoreProcessingStatsResponse +{ + [JsonConstructor] + public ScoreProcessingStatsResponse() + { + } + + public ScoreProcessingStatsResponse(long pending, long processing, long failed, double? estimatedPendingCompletionSeconds) + { + Pending = pending; + Processing = processing; + Failed = failed; + EstimatedPendingCompletionSeconds = estimatedPendingCompletionSeconds; + } + + [JsonPropertyName("pending")] + public long Pending { get; set; } + + [JsonPropertyName("processing")] + public long Processing { get; set; } + + [JsonPropertyName("failed")] + public long Failed { get; set; } + + [JsonPropertyName("estimated_pending_completion_seconds")] + public double? EstimatedPendingCompletionSeconds { get; set; } +} \ No newline at end of file diff --git a/Sunrise.API/Serializable/Response/ScoreProcessingTaskResponse.cs b/Sunrise.API/Serializable/Response/ScoreProcessingTaskResponse.cs new file mode 100644 index 00000000..e7d38dd2 --- /dev/null +++ b/Sunrise.API/Serializable/Response/ScoreProcessingTaskResponse.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Repositories; + +namespace Sunrise.API.Serializable.Response; + +public class ScoreProcessingTaskResponse +{ + [JsonConstructor] + public ScoreProcessingTaskResponse() + { + } + + public ScoreProcessingTaskResponse(SessionRepository sessionRepository, ScoreProcessingTask task) + { + Id = task.Id; + TaskType = task.TaskType; + Status = task.Status; + Priority = task.Priority; + RetryCount = task.RetryCount; + ErrorCode = task.ErrorCode; + ErrorMessage = task.ErrorMessage; + NextRetryAt = task.NextRetryAt; + CreatedAt = task.CreatedAt; + ScoreId = task.ScoreId; + Score = task.Score != null ? new AdminScoreResponse(sessionRepository, task.Score) : null; + } + + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("task_type")] + public ScoreTaskType TaskType { get; set; } + + [JsonPropertyName("status")] + public ScoreProcessingStatus Status { get; set; } + + [JsonPropertyName("priority")] + public int Priority { get; set; } + + [JsonPropertyName("retry_count")] + public int RetryCount { get; set; } + + [JsonPropertyName("error_code")] + public ScoreProcessingErrorCode? ErrorCode { get; set; } + + [JsonPropertyName("error_message")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("next_retry_at")] + public DateTime? NextRetryAt { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("score_id")] + public int? ScoreId { get; set; } + + [JsonPropertyName("score")] + public AdminScoreResponse? Score { get; set; } +} diff --git a/Sunrise.API/Serializable/Response/ScoreProcessingTasksResponse.cs b/Sunrise.API/Serializable/Response/ScoreProcessingTasksResponse.cs new file mode 100644 index 00000000..95f22654 --- /dev/null +++ b/Sunrise.API/Serializable/Response/ScoreProcessingTasksResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Sunrise.API.Serializable.Response; + +public class ScoreProcessingTasksResponse(List tasks, int totalCount) +{ + [JsonPropertyName("tasks")] + public List Tasks { get; set; } = tasks; + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } = totalCount; +} diff --git a/Sunrise.API/Services/ScoreProcessingService.cs b/Sunrise.API/Services/ScoreProcessingService.cs new file mode 100644 index 00000000..d572f695 --- /dev/null +++ b/Sunrise.API/Services/ScoreProcessingService.cs @@ -0,0 +1,254 @@ +using System.Net; +using Hangfire; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using osu.Shared; +using Sunrise.API.Extensions; +using Sunrise.API.Objects.Keys; +using Sunrise.API.Serializable.Request; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Extensions; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Objects; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Jobs; +using Sunrise.Shared.Repositories; + +namespace Sunrise.API.Services; + +public class ScoreProcessingService(DatabaseService database, SessionRepository sessions) +{ + private const int MaxBulkIds = 100; + + private static readonly ScoreTaskType[] AllowedActions = [ScoreTaskType.Recalculation, ScoreTaskType.Restore, ScoreTaskType.Delete]; + + public async Task GetTasks( + int page, + int limit, + ScoreProcessingStatus? status, + ScoreTaskType? taskType, + int? scoreId, + int? taskId, + CancellationToken ct = default) + { + var (tasks, totalCount) = await database.ScoreProcessingTasks.GetTasks( + new QueryOptions(true, new Pagination(page, limit)) + { + QueryModifier = q => q.Cast() + .Include(task => task.Score) + .Include(task => task.Score!.User) + }, + status, + taskType, + scoreId, + taskId, + ct); + + var parsed = tasks.Select(task => new ScoreProcessingTaskResponse(sessions, task)).ToList(); + + return new OkObjectResult(new ScoreProcessingTasksResponse(parsed, totalCount)); + } + + public async Task GetTask(int taskId, CancellationToken ct = default) + { + var task = await database.ScoreProcessingTasks.GetTaskById(taskId, + new QueryOptions(true) + { + QueryModifier = query => query.Cast() + .Include(t => t.Score) + .Include(t => t.Score!.User) + }, + ct); + + if (task == null) + return ApiErrorResponse.Detail.ScoreTaskNotFound.ToProblemResult(HttpStatusCode.NotFound); + + return new OkObjectResult(new ScoreProcessingTaskResponse(sessions, task)); + } + + public async Task GetPreview(int scoreId, CancellationToken ct = default) + { + var score = await database.Scores.GetScore(scoreId, + new QueryOptions(true) + { + QueryModifier = query => query.Cast().IncludeUser() + }, + false, + ct); + + if (score == null) + return ApiErrorResponse.Detail.ScoreNotFound.ToProblemResult(HttpStatusCode.NotFound); + + var activeTask = await database.ScoreProcessingTasks.GetActiveTaskByScoreId(scoreId, ct); + + var preview = new ScoreProcessingPreviewResponse( + new AdminScoreResponse(sessions, score), + activeTask != null ? new ScoreProcessingTaskResponse(sessions, activeTask) : null); + + return new OkObjectResult(preview); + } + + public async Task CreateTask(int executorId, int scoreId, ScoreTaskType action, CancellationToken ct = default) + { + if (!AllowedActions.Contains(action)) + return ApiErrorResponse.Detail.InvalidScoreProcessingAction.ToProblemResult(HttpStatusCode.BadRequest, ApiErrorResponse.Title.UnableToQueueScoreProcessing); + + var score = await database.Scores.GetScore(scoreId, filterValidScores: false, ct: ct); + + if (score == null) + return ApiErrorResponse.Detail.ScoreNotFound.ToProblemResult(HttpStatusCode.NotFound); + + var task = new ScoreProcessingTask + { + TaskType = action, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }; + + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(task, ct); + + if (!queued) + return ApiErrorResponse.Detail.ScoreAlreadyQueued.ToProblemResult(HttpStatusCode.Conflict, ApiErrorResponse.Title.UnableToQueueScoreProcessing); + + await database.Events.ScoreProcessing.AddActionRequestedEvent(executorId, score.Id, task.Id, action, task.Priority, ct); + + var created = await database.ScoreProcessingTasks.GetTaskById(task.Id, + new QueryOptions(true) + { + QueryModifier = query => query.Cast() + .Include(t => t.Score) + .Include(t => t.Score!.User) + }, + ct); + + if (created == null) + return ApiErrorResponse.Detail.ScoreTaskNotFound.ToProblemResult(HttpStatusCode.NotFound); + + return new ObjectResult(new ScoreProcessingTaskResponse(sessions, created)) + { + StatusCode = StatusCodes.Status201Created + }; + } + + public async Task CancelTask(int executorId, int taskId, CancellationToken ct = default) + { + var task = await database.ScoreProcessingTasks.GetTaskById(taskId, ct: ct); + + if (task == null) + return ApiErrorResponse.Detail.ScoreTaskNotFound.ToProblemResult(HttpStatusCode.NotFound); + + var result = await database.ScoreProcessingTasks.CancelTask(taskId, ct); + + if (result.IsFailure) + return result.Error.ToProblemResult(HttpStatusCode.Conflict, ApiErrorResponse.Title.UnableToCancelScoreTask); + + await database.Events.ScoreProcessing.AddCancelledEvent(executorId, taskId, task.ScoreId, ct); + + return new OkResult(); + } + + public async Task Requeue(int executorId, int taskId, CancellationToken ct = default) + { + var task = await database.ScoreProcessingTasks.GetTaskById(taskId, ct: ct); + + if (task == null) + return ApiErrorResponse.Detail.ScoreTaskNotFound.ToProblemResult(HttpStatusCode.NotFound); + + var priorErrorCode = task.ErrorCode; + var priorErrorMessage = task.ErrorMessage; + + var result = await database.ScoreProcessingTasks.TryRequeueFailedTask(taskId, ct); + + if (result.IsFailure) + return result.Error.ToProblemResult(HttpStatusCode.Conflict, ApiErrorResponse.Title.UnableToRequeueScoreTask); + + await database.Events.ScoreProcessing.AddRequeuedEvent(executorId, taskId, task.ScoreId, priorErrorCode, priorErrorMessage, ct); + + return new OkResult(); + } + + public async Task BulkByIds(int executorId, List scoreIds, ScoreTaskType action, CancellationToken ct = default) + { + if (!AllowedActions.Contains(action)) + return ApiErrorResponse.Detail.InvalidScoreProcessingAction.ToProblemResult(HttpStatusCode.BadRequest, ApiErrorResponse.Title.UnableToQueueScoreProcessing); + + var distinctIds = scoreIds.Distinct().ToList(); + + if (distinctIds.Count == 0) + return ApiErrorResponse.Detail.InvalidQueryParameters.ToProblemResult(HttpStatusCode.BadRequest, ApiErrorResponse.Title.UnableToQueueScoreProcessing); + + if (distinctIds.Count > MaxBulkIds) + return ApiErrorResponse.Detail.TooManyScoreIds.ToProblemResult(HttpStatusCode.BadRequest, ApiErrorResponse.Title.UnableToQueueScoreProcessing); + + var queuedTasks = await database.ScoreProcessingTasks.BulkAddScoreTasks(distinctIds, action, ScoreProcessingPriority.Normal, ct); + await database.Events.ScoreProcessing.AddActionRequestedEvents(executorId, queuedTasks, action, ct); + + return new OkObjectResult(new BulkScoreProcessingResultResponse(queuedTasks.Count, distinctIds.Count - queuedTasks.Count)); + } + + public IActionResult BulkByFilter(int? executorId, BulkScoreProcessingByFilterRequest request) + { + if (!AllowedActions.Contains(request.Action)) + return ApiErrorResponse.Detail.InvalidScoreProcessingAction.ToProblemResult(HttpStatusCode.BadRequest, ApiErrorResponse.Title.UnableToQueueScoreProcessing); + + var modsEnum = (request.Mods ?? Array.Empty()).Aggregate(Mods.None, (current, mod) => current | mod); + + BackgroundJob.Enqueue(service => service.EnqueueByFilter( + executorId, + request.Action, + request.UserId, + request.Mode, + request.Mods != null ? modsEnum : null, + request.SubmissionStatus, + request.BeatmapStatus, + request.SubmittedFrom, + request.SubmittedTo, + CancellationToken.None)); + + return new OkResult(); + } + + public async Task GetStats(CancellationToken ct = default) + { + var counts = await database.ScoreProcessingTasks.CountByStatus(ct); + + var pending = counts.GetValueOrDefault(ScoreProcessingStatus.Pending); + var processing = counts.GetValueOrDefault(ScoreProcessingStatus.Processing); + var failed = counts.GetValueOrDefault(ScoreProcessingStatus.Failed); + + return new OkObjectResult(new ScoreProcessingStatsResponse(pending, processing, failed, EstimatePendingCompletionSeconds(pending))); + } + + public async Task GetEvents(int page, int limit, List? types, int? scoreId, CancellationToken ct = default) + { + var (events, totalCount) = await database.Events.ScoreProcessing.GetEvents( + new QueryOptions(true, new Pagination(page, limit)), + types, + scoreId, + ct); + + var parsed = events.Select(scoreProcessingEvent => new EventScoreProcessingResponse(sessions, scoreProcessingEvent)).ToList(); + + return new OkObjectResult(new EventScoreProcessingListResponse(parsed, totalCount)); + } + + private static double? EstimatePendingCompletionSeconds(long pending) + { + if (pending <= 0) + return null; + + var concurrency = Math.Max(1, Configuration.ScoreProcessingMaxConcurrency); + var batches = Math.Ceiling(pending / (double)concurrency); + + var avgTaskDuration = SunriseMetrics.GetEstimatedAverageTaskDurationSeconds(); + var secondsPerTask = avgTaskDuration > 0 ? avgTaskDuration : Configuration.ScoreProcessingTimeoutSeconds; + var secondsPerBatch = secondsPerTask + Configuration.ScoreProcessingPollerInterBatchDelaySeconds; + + return batches * secondsPerBatch; + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Jobs/ScoreProcessingJob.cs b/Sunrise.Processing/Scores/Jobs/ScoreProcessingJob.cs index 8c1bb48a..18bff562 100644 --- a/Sunrise.Processing/Scores/Jobs/ScoreProcessingJob.cs +++ b/Sunrise.Processing/Scores/Jobs/ScoreProcessingJob.cs @@ -95,6 +95,8 @@ await Parallel.ForEachAsync(claimed, private async Task ProcessEntry(ScoreProcessingTask task, CancellationToken ct) { + var startTime = DateTime.UtcNow; + using var entryScope = scopeFactory.CreateScope(); var entryDatabase = entryScope.ServiceProvider.GetRequiredService(); int? affectedUserId = null; @@ -155,6 +157,10 @@ private async Task ProcessEntry(ScoreProcessingTask task, CancellationToken ct) { await HandleUnexpectedEntryException(task, affectedUserId, ex); } + finally + { + RecordTaskDuration(task.TaskType, startTime); + } } private static void NotifyUserOfPermanentFailure(SessionRepository sessions, ScoreProcessingTask task, int? affectedUserId) @@ -227,4 +233,10 @@ private static TimeSpan GetBackoffDelay(int retryCount) var index = Math.Min(retryCount, schedule.Length - 1); return schedule[index]; } + + private static void RecordTaskDuration(ScoreTaskType taskType, DateTime startTime) + { + var duration = (DateTime.UtcNow - startTime).TotalSeconds; + SunriseMetrics.RecordScoreProcessingTaskDuration(duration, taskType); + } } \ No newline at end of file diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingBulkByFilterTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingBulkByFilterTests.cs new file mode 100644 index 00000000..a6d16e8d --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingBulkByFilterTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Net.Http.Json; +using Hangfire; +using Microsoft.AspNetCore.Mvc; +using Sunrise.API.Objects.Keys; +using Sunrise.API.Serializable.Request; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Jobs; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingBulkByFilterTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestBulkByFilterWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk-by-filter", + new BulkScoreProcessingByFilterRequest + { + Action = ScoreTaskType.Delete, + UserId = 1 + }); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestBulkByFilterRejectsInvalidAction() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk-by-filter", + new BulkScoreProcessingByFilterRequest + { + Action = ScoreTaskType.Submission, + UserId = 1 + }); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var responseError = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.Contains(ApiErrorResponse.Detail.InvalidScoreProcessingAction, responseError?.Detail); + } + + [Fact] + public async Task TestBulkByFilterQueuesBackgroundJobToCreateScoreProcessingTask() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk-by-filter", + new BulkScoreProcessingByFilterRequest + { + Mode = score.GameMode, + UserId = score.UserId, + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var api = JobStorage.Current.GetMonitoringApi(); + var enqueued = api.EnqueuedJobs("default", 0, 100); + + Assert.NotEmpty(enqueued); + + Assert.Contains(enqueued, job => job.Value.Job.Type == typeof(BulkScoreProcessingJob)); + } +} \ No newline at end of file diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingBulkTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingBulkTests.cs new file mode 100644 index 00000000..b2c5a07d --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingBulkTests.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc; +using Sunrise.API.Objects.Keys; +using Sunrise.API.Serializable.Request; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingBulkTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestBulkByIdsWithoutAuthToken() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk", + new BulkScoreProcessingRequest + { + ScoreIds = [1], + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestBulkByIdsWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk", + new BulkScoreProcessingRequest + { + ScoreIds = [1], + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestBulkByIdsQueuesExistingScoresAndSkipsMissing() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var firstScore = await CreateTestScore(); + var secondScore = await CreateTestScore(); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk", + new BulkScoreProcessingRequest + { + ScoreIds = [firstScore.Id, secondScore.Id, 999999], + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(2, result.Queued); + Assert.Equal(1, result.Skipped); + + var (_, totalEvents) = await Database.Events.ScoreProcessing.GetEvents(); + Assert.Equal(2, totalEvents); + } + + [Fact] + public async Task TestBulkByIdsQueuesExistingScoresAndCreatesScoreProcessingTask() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var firstScore = await CreateTestScore(); + var secondScore = await CreateTestScore(); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk", + new BulkScoreProcessingRequest + { + ScoreIds = [firstScore.Id, secondScore.Id], + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(2, result.Queued); + + var (_, totalTasks) = await Database.ScoreProcessingTasks.GetTasks(); + Assert.Equal(2, totalTasks); + } + + [Fact] + public async Task TestBulkByIdsRejectsIfScoreIdsCountViolatesMaxCount() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsJsonAsync("score-processing/bulk", + new BulkScoreProcessingRequest + { + ScoreIds = Enumerable.Range(1, 101).ToList(), + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var responseError = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.Contains(ApiErrorResponse.Detail.TooManyScoreIds, responseError?.Detail); + } +} \ No newline at end of file diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingCancelTaskTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingCancelTaskTests.cs new file mode 100644 index 00000000..ff5ea178 --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingCancelTaskTests.cs @@ -0,0 +1,140 @@ +using System.Net; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingCancelTaskTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestCancelTaskWithoutAuthToken() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + // Act + var response = await client.PostAsync("score-processing/1/cancel", null); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestCancelTaskWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsync("score-processing/1/cancel", null); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestCancelTaskWithMissingTask() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsync("score-processing/999999/cancel", null); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task TestCancelPendingTaskSucceedsAndRecordsEvent() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + var task = new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Status = ScoreProcessingStatus.Pending, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }; + await Database.ScoreProcessingTasks.AddQueueEntry(task); + + // Act + var response = await client.PostAsync($"score-processing/{task.Id}/cancel", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedTask = await Database.ScoreProcessingTasks.GetTaskById(task.Id); + Assert.NotNull(updatedTask); + Assert.Equal(ScoreProcessingStatus.Failed, updatedTask.Status); + Assert.Equal(ScoreProcessingErrorCode.CancelledByOperator, updatedTask.ErrorCode); + + var (events, _) = await Database.Events.ScoreProcessing.GetEvents(types: [ScoreProcessingEventType.Cancelled]); + Assert.Single(events); + Assert.Equal(task.Id, events.Single().TaskId); + } + + [Fact] + public async Task TestCancelProcessingTaskReturnsConflict() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + var task = new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Status = ScoreProcessingStatus.Processing, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }; + await Database.ScoreProcessingTasks.AddQueueEntry(task); + + // Act + var response = await client.PostAsync($"score-processing/{task.Id}/cancel", null); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + var updatedTask = await Database.ScoreProcessingTasks.GetTaskById(task.Id); + Assert.NotNull(updatedTask); + Assert.Equal(ScoreProcessingStatus.Processing, updatedTask.Status); + } +} \ No newline at end of file diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingCreateTaskTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingCreateTaskTests.cs new file mode 100644 index 00000000..f73ab1bf --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingCreateTaskTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc; +using Sunrise.API.Objects.Keys; +using Sunrise.API.Serializable.Request; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingCreateTaskTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestCreateTaskWithoutAuthToken() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + // Act + var response = await client.PostAsJsonAsync("score-processing", + new CreateScoreProcessingTaskRequest + { + ScoreId = 1, + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestCreateTaskWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsJsonAsync("score-processing", + new CreateScoreProcessingTaskRequest + { + ScoreId = 1, + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestCreateTaskWithMissingScore() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.PostAsJsonAsync("score-processing", + new CreateScoreProcessingTaskRequest + { + ScoreId = 999999, + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var responseError = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.Contains(ApiErrorResponse.Detail.ScoreNotFound, responseError?.Detail); + } + + [Fact] + public async Task TestCreateTaskWithInvalidAction() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + + // Act + var response = await client.PostAsJsonAsync("score-processing", + new CreateScoreProcessingTaskRequest + { + ScoreId = score.Id, + Action = ScoreTaskType.Submission + }); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var responseError = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.Contains(ApiErrorResponse.Title.UnableToQueueScoreProcessing, responseError?.Title); + Assert.Contains(ApiErrorResponse.Detail.InvalidScoreProcessingAction, responseError?.Detail); + } + + [Fact] + public async Task TestCreateTaskConflictWhenActiveTaskExists() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + + // Act + var firstResponse = await client.PostAsJsonAsync("score-processing", + new CreateScoreProcessingTaskRequest + { + ScoreId = score.Id, + Action = ScoreTaskType.Delete + }); + + var secondResponse = await client.PostAsJsonAsync("score-processing", + new CreateScoreProcessingTaskRequest + { + ScoreId = score.Id, + Action = ScoreTaskType.Delete + }); + + // Assert + Assert.Equal(HttpStatusCode.Created, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); + + var responseError = await secondResponse.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.Contains(ApiErrorResponse.Title.UnableToQueueScoreProcessing, responseError?.Title); + Assert.Contains(ApiErrorResponse.Detail.ScoreAlreadyQueued, responseError?.Detail); + } + + [Fact] + public async Task TestCreateTaskSuccessQueuesTaskAndRecordsEvent() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + + // Act + var response = await client.PostAsJsonAsync("score-processing", + new CreateScoreProcessingTaskRequest + { + ScoreId = score.Id, + Action = ScoreTaskType.Recalculation + }); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var task = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(task); + Assert.Equal(ScoreTaskType.Recalculation, task.TaskType); + Assert.Equal(ScoreProcessingStatus.Pending, task.Status); + Assert.Equal(score.Id, task.ScoreId); + + var (tasks, totalTasks) = await Database.ScoreProcessingTasks.GetTasks(scoreId: score.Id); + Assert.Equal(1, totalTasks); + Assert.Equal(ScoreTaskType.Recalculation, tasks.Single().TaskType); + + var (events, totalEvents) = await Database.Events.ScoreProcessing.GetEvents(); + Assert.Equal(1, totalEvents); + var recordedEvent = events.Single(); + Assert.Equal(ScoreProcessingEventType.RecalculationRequested, recordedEvent.EventType); + Assert.Equal(score.Id, recordedEvent.ScoreId); + Assert.Equal(superUser.Id, recordedEvent.ExecutorId); + } +} diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingEventsTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingEventsTests.cs new file mode 100644 index 00000000..a98ae4a8 --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingEventsTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingEventsTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestGetEventsWithoutAuthToken() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + // Act + var response = await client.GetAsync("score-processing/events"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestGetEventsWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.GetAsync("score-processing/events"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestGetEventsReturnsServerEventWithNullExecutor() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + await Database.Events.ScoreProcessing.AddSubmissionEnqueuedEvent(123, 456, 789); + + // Act + var response = await client.GetAsync("score-processing/events"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(1, result.TotalCount); + + var serverEvent = result.Events.Single(); + Assert.Equal(ScoreProcessingEventType.SubmissionEnqueued, serverEvent.EventType); + Assert.Null(serverEvent.Executor); + } + + [Fact] + public async Task TestGetEventsFiltersByType() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var executor = await CreateTestUser(); + var score = await CreateTestScore(); + + await Database.Events.ScoreProcessing.AddSubmissionEnqueuedEvent(1, 2, 3); + await Database.Events.ScoreProcessing.AddActionRequestedEvent(executor.Id, score.Id, 99, ScoreTaskType.Delete, (int)ScoreProcessingPriority.Normal); + + // Act + var response = await client.GetAsync("score-processing/events?types=DeleteRequested"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(1, result.TotalCount); + Assert.Equal(ScoreProcessingEventType.DeleteRequested, result.Events.Single().EventType); + } + + [Fact] + public async Task TestGetEventsFiltersByScoreId() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var executor = await CreateTestUser(); + var score = await CreateTestScore(); + + await Database.Events.ScoreProcessing.AddActionRequestedEvent(executor.Id, score.Id, 1, ScoreTaskType.Delete, (int)ScoreProcessingPriority.Normal); + await Database.Events.ScoreProcessing.AddSubmissionEnqueuedEvent(1, 2, 3); + + // Act + var response = await client.GetAsync($"score-processing/events?score_id={score.Id}"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(1, result.TotalCount); + Assert.Equal(score.Id, result.Events.Single().ScoreId); + } +} diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingGetTasksTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingGetTasksTests.cs new file mode 100644 index 00000000..7866f2dd --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingGetTasksTests.cs @@ -0,0 +1,166 @@ +using System.Net; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingGetTasksTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestGetTasksWithoutAuthToken() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + // Act + var response = await client.GetAsync("score-processing"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestGetTasksWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.GetAsync("score-processing"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestGetTasksReturnsTasksWithScorePreview() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + + await Database.ScoreProcessingTasks.AddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }); + + // Act + var response = await client.GetAsync("score-processing"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(1, result.TotalCount); + + var task = result.Tasks.Single(); + Assert.Equal(score.Id, task.ScoreId); + Assert.NotNull(task.Score); + Assert.Equal(score.Id, task.Score.Score.Id); + } + + [Fact] + public async Task TestGetTasksFiltersByTaskType() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var deleteScore = await CreateTestScore(); + await Database.ScoreProcessingTasks.AddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = deleteScore.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }); + + var recalculationScore = await CreateTestScore(); + await Database.ScoreProcessingTasks.AddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = recalculationScore.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }); + + // Act + var response = await client.GetAsync("score-processing?task_type=Delete"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(1, result.TotalCount); + Assert.Equal(ScoreTaskType.Delete, result.Tasks.Single().TaskType); + } + + [Fact] + public async Task TestGetTasksRespectsPagination() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + for (var i = 0; i < 3; i++) + { + var score = await CreateTestScore(); + await Database.ScoreProcessingTasks.AddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }); + } + + // Act + var response = await client.GetAsync("score-processing?limit=2&page=1"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.Tasks.Count); + } +} diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingPreviewTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingPreviewTests.cs new file mode 100644 index 00000000..b463aeb2 --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingPreviewTests.cs @@ -0,0 +1,122 @@ +using System.Net; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingPreviewTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestPreviewWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.GetAsync("score-processing/score/1"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestPreviewWithMissingScore() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.GetAsync("score-processing/score/999999"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task TestPreviewReturnsScoreWithoutActiveTask() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + + // Act + var response = await client.GetAsync($"score-processing/score/{score.Id}"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var preview = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(preview); + Assert.Equal(score.Id, preview.Score.Score.Id); + Assert.Null(preview.ActiveTask); + } + + [Fact] + public async Task TestPreviewReturnsDeletedScoreWithActiveTask() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var scoreUser = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(scoreUser); + score.SubmissionStatus = SubmissionStatus.Deleted; + await Database.Scores.AddScore(score); + + await Database.ScoreProcessingTasks.AddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }); + + // Act + var response = await client.GetAsync($"score-processing/score/{score.Id}"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var preview = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(preview); + Assert.Equal(score.Id, preview.Score.Score.Id); + Assert.Equal(SubmissionStatus.Deleted, preview.Score.SubmissionStatus); + Assert.NotNull(preview.ActiveTask); + Assert.Equal(ScoreTaskType.Restore, preview.ActiveTask.TaskType); + } +} diff --git a/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingStatsTests.cs b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingStatsTests.cs new file mode 100644 index 00000000..4c0c51d5 --- /dev/null +++ b/Sunrise.Server.Tests/API/ScoreProcessingController/ApiScoreProcessingStatsTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; + +namespace Sunrise.Server.Tests.API.ScoreProcessingController; + +[Collection("Integration tests collection")] +public class ApiScoreProcessingStatsTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestGetStatsWithoutAuthToken() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + // Act + var response = await client.GetAsync("score-processing/stats"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestGetStatsWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.GetAsync("score-processing/stats"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestGetStatsReturnsCountsAndEta() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + foreach (var status in new[] { ScoreProcessingStatus.Pending, ScoreProcessingStatus.Pending, ScoreProcessingStatus.Processing, ScoreProcessingStatus.Failed }) + { + var score = await CreateTestScore(); + await Database.ScoreProcessingTasks.AddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Status = status, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }); + } + + // Act + var response = await client.GetAsync("score-processing/stats"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var stats = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(stats); + Assert.Equal(2, stats.Pending); + Assert.Equal(1, stats.Processing); + Assert.Equal(1, stats.Failed); + Assert.NotNull(stats.EstimatedPendingCompletionSeconds); + Assert.True(stats.EstimatedPendingCompletionSeconds > 0); + } + + [Fact] + public async Task TestGetStatsReturnsNullEtaWhenNoPending() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var score = await CreateTestScore(); + await Database.ScoreProcessingTasks.AddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Status = ScoreProcessingStatus.Failed, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }); + + // Act + var response = await client.GetAsync("score-processing/stats"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var stats = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(stats); + Assert.Equal(0, stats.Pending); + Assert.Equal(1, stats.Failed); + Assert.Null(stats.EstimatedPendingCompletionSeconds); + } +} diff --git a/Sunrise.Server.Tests/API/UserController/ApiAdminGetUserScoresAdminTests.cs b/Sunrise.Server.Tests/API/UserController/ApiAdminGetUserScoresAdminTests.cs new file mode 100644 index 00000000..a00b7845 --- /dev/null +++ b/Sunrise.Server.Tests/API/UserController/ApiAdminGetUserScoresAdminTests.cs @@ -0,0 +1,183 @@ +using System.Net; +using osu.Shared; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Enums.Users; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Server.Tests.API.UserController; + +[Collection("Integration tests collection")] +public class ApiAdminGetUserScoresAdminTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestGetUserScoresAdminWithoutAuthToken() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var targetUser = await CreateTestUser(); + + // Act + var response = await client.GetAsync($"user/{targetUser.Id}/scores/admin"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestGetUserScoresAdminWithNonSuperUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var regularUser = await CreateTestUser(); + var targetUser = await CreateTestUser(); + + var tokens = await GetUserAuthTokens(regularUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.GetAsync($"user/{targetUser.Id}/scores/admin"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TestGetUserScoresAdminWithMissingUser() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + // Act + var response = await client.GetAsync("user/999999/scores/admin"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task TestGetUserScoresAdminIncludesDeletedScores() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var targetUser = await CreateTestUser(); + + var bestScore = _mocker.Score.GetBestScoreableRandomScore(); + bestScore.EnrichWithUserData(targetUser); + bestScore.SubmissionStatus = SubmissionStatus.Best; + await Database.Scores.AddScore(bestScore); + + var deletedScore = _mocker.Score.GetBestScoreableRandomScore(); + deletedScore.EnrichWithUserData(targetUser); + deletedScore.SubmissionStatus = SubmissionStatus.Deleted; + await Database.Scores.AddScore(deletedScore); + + // Act + var response = await client.GetAsync($"user/{targetUser.Id}/scores/admin"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(2, result.TotalCount); + } + + [Fact] + public async Task TestGetUserScoresAdminFiltersByMods() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var targetUser = await CreateTestUser(); + + var hiddenScore = _mocker.Score.GetBestScoreableRandomScore(); + hiddenScore.EnrichWithUserData(targetUser); + hiddenScore.Mods = Mods.Hidden; + await Database.Scores.AddScore(hiddenScore); + + var noModScore = _mocker.Score.GetBestScoreableRandomScore(); + noModScore.EnrichWithUserData(targetUser); + noModScore.Mods = Mods.None; + await Database.Scores.AddScore(noModScore); + + var hiddenBit = (int)Mods.Hidden; + + // Act + var response = await client.GetAsync($"user/{targetUser.Id}/scores/admin?mods={hiddenBit}"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(1, result.TotalCount); + Assert.Equal(hiddenScore.Id, result.Scores.Single().Score.Id); + } + + [Fact] + public async Task TestGetUserScoresAdminFiltersBySubmissionStatus() + { + // Arrange + var client = App.CreateClient().UseClient("api"); + + var superUser = _mocker.User.GetRandomUser(); + superUser.Privilege = UserPrivilege.SuperUser; + await CreateTestUser(superUser); + + var tokens = await GetUserAuthTokens(superUser); + client.UseUserAuthToken(tokens); + + var targetUser = await CreateTestUser(); + + var bestScore = _mocker.Score.GetBestScoreableRandomScore(); + bestScore.EnrichWithUserData(targetUser); + bestScore.SubmissionStatus = SubmissionStatus.Best; + await Database.Scores.AddScore(bestScore); + + var deletedScore = _mocker.Score.GetBestScoreableRandomScore(); + deletedScore.EnrichWithUserData(targetUser); + deletedScore.SubmissionStatus = SubmissionStatus.Deleted; + await Database.Scores.AddScore(deletedScore); + + // Act + var response = await client.GetAsync($"user/{targetUser.Id}/scores/admin?submission_status=Deleted"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(result); + Assert.Equal(1, result.TotalCount); + Assert.Equal(SubmissionStatus.Deleted, result.Scores.Single().SubmissionStatus); + } +} \ No newline at end of file diff --git a/Sunrise.Server/Bootstrap.cs b/Sunrise.Server/Bootstrap.cs index 11cc4f69..68dd1502 100644 --- a/Sunrise.Server/Bootstrap.cs +++ b/Sunrise.Server/Bootstrap.cs @@ -57,6 +57,7 @@ using Swashbuckle.AspNetCore.SwaggerGen; using AssetService = Sunrise.API.Services.AssetService; using AuthService = Sunrise.API.Services.AuthService; +using ScoreProcessingService = Sunrise.API.Services.ScoreProcessingService; using UserService = Sunrise.API.Services.UserService; using WebSocketManager = Sunrise.API.Managers.WebSocketManager; @@ -408,6 +409,7 @@ public static void AddApiEndpoints(this WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } public static void AddDatabaseServices(this WebApplicationBuilder builder) @@ -445,6 +447,7 @@ public static void AddDatabaseServices(this WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Sunrise.Server/Commands/ChatCommands/System/CancelScoreTaskCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/CancelScoreTaskCommand.cs index bf35f955..56f6c54d 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/CancelScoreTaskCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/CancelScoreTaskCommand.cs @@ -23,13 +23,24 @@ public async Task Handle(Session session, ChatChannel? channel, string[]? args) using var scope = ServicesProviderHolder.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); + var task = await database.ScoreProcessingTasks.GetTaskById(taskId); + + if (task == null) + { + ChatCommandRepository.SendMessage(session, $"Score task {taskId} does not exist."); + return; + } + var cancelResult = await database.ScoreProcessingTasks.CancelTask(taskId); + if (cancelResult.IsFailure) { ChatCommandRepository.SendMessage(session, cancelResult.Error); return; } + await database.Events.ScoreProcessing.AddCancelledEvent(session.UserId, taskId, task?.ScoreId); + ChatCommandRepository.SendMessage(session, $"Score task {taskId} was cancelled."); } -} +} \ No newline at end of file diff --git a/Sunrise.Server/Commands/ChatCommands/System/DeleteScoreCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/DeleteScoreCommand.cs index bc12ecd5..6ff5b6b3 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/DeleteScoreCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/DeleteScoreCommand.cs @@ -47,14 +47,15 @@ await BackgroundTaskService.ExecuteBackgroundTask( return; } - var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(new ScoreProcessingTask - { - TaskType = ScoreTaskType.Delete, - ScoreId = score.Id, - Priority = (int)ScoreProcessingPriority.Normal, - CreatedAt = DateTime.UtcNow - }, - ct); + var task = new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }; + + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(task, ct); if (!queued) { @@ -62,6 +63,8 @@ await BackgroundTaskService.ExecuteBackgroundTask( return; } + await database.Events.ScoreProcessing.AddActionRequestedEvent(userId, score.Id, task.Id, ScoreTaskType.Delete, task.Priority, ct); + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was queued for deletion."); }, message => ChatCommandRepository.TrySendMessage(userId, message)); diff --git a/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoreCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoreCommand.cs index 9e78bdcf..6c460a83 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoreCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoreCommand.cs @@ -47,14 +47,15 @@ await BackgroundTaskService.ExecuteBackgroundTask( return; } - var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(new ScoreProcessingTask - { - TaskType = ScoreTaskType.Recalculation, - ScoreId = score.Id, - Priority = (int)ScoreProcessingPriority.Normal, - CreatedAt = DateTime.UtcNow - }, - ct); + var task = new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }; + + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(task, ct); if (!queued) { @@ -62,6 +63,8 @@ await BackgroundTaskService.ExecuteBackgroundTask( return; } + await database.Events.ScoreProcessing.AddActionRequestedEvent(userId, score.Id, task.Id, ScoreTaskType.Recalculation, task.Priority, ct); + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was queued for recalculation."); }, message => ChatCommandRepository.TrySendMessage(userId, message)); diff --git a/Sunrise.Server/Commands/ChatCommands/System/RequeueFailedScoresCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RequeueFailedScoresCommand.cs index ea26b538..b00cca04 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/RequeueFailedScoresCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/RequeueFailedScoresCommand.cs @@ -31,7 +31,7 @@ public async Task Handle(Session session, ChatChannel? channel, string[]? args) var database = scope.ServiceProvider.GetRequiredService(); var requeuedCount = taskId.HasValue - ? await database.ScoreProcessingTasks.TryRequeueFailedTask(taskId.Value) ? 1 : 0 + ? (await database.ScoreProcessingTasks.TryRequeueFailedTask(taskId.Value)).IsSuccess ? 1 : 0 : await database.ScoreProcessingTasks.TryRequeueFailedTasks(); ChatCommandRepository.SendMessage(session, $"Requeued {requeuedCount} failed score-processing {(requeuedCount == 1 ? "task" : "tasks")}."); diff --git a/Sunrise.Server/Commands/ChatCommands/System/RestoreScoreCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RestoreScoreCommand.cs index d2d37168..b0c55b30 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/RestoreScoreCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/RestoreScoreCommand.cs @@ -47,14 +47,15 @@ await BackgroundTaskService.ExecuteBackgroundTask( return; } - var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(new ScoreProcessingTask - { - TaskType = ScoreTaskType.Restore, - ScoreId = score.Id, - Priority = (int)ScoreProcessingPriority.Normal, - CreatedAt = DateTime.UtcNow - }, - ct); + var task = new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }; + + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(task, ct); if (!queued) { @@ -62,6 +63,8 @@ await BackgroundTaskService.ExecuteBackgroundTask( return; } + await database.Events.ScoreProcessing.AddActionRequestedEvent(userId, score.Id, task.Id, ScoreTaskType.Restore, task.Priority, ct); + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was queued for restore."); }, message => ChatCommandRepository.TrySendMessage(userId, message)); diff --git a/Sunrise.Server/Services/ScoreService.cs b/Sunrise.Server/Services/ScoreService.cs index 3e4f0b82..5cbbdd32 100644 --- a/Sunrise.Server/Services/ScoreService.cs +++ b/Sunrise.Server/Services/ScoreService.cs @@ -180,6 +180,8 @@ private async Task EnqueueForBackgroundRetry(ScoreSubmissionRequest candidate, S var shouldParkAsFailed = error is { Disposition: ScoreProcessingDisposition.Permanent } || error.HasValue && Configuration.ScoreProcessingMaxRetries <= 0; + int? enqueuedTaskId = null; + var enqueueResult = await database.CommitAsTransactionAsync(async () => { await database.ScoreSubmissionRequests.AddQueueEntry(candidate); @@ -201,11 +203,14 @@ private async Task EnqueueForBackgroundRetry(ScoreSubmissionRequest candidate, S } await database.ScoreProcessingTasks.AddQueueEntry(task); + enqueuedTaskId = task.Id; }); if (enqueueResult.IsFailure) throw new Exception($"Failed to enqueue score for background retry: {enqueueResult.Error}"); + await database.Events.ScoreProcessing.AddSubmissionEnqueuedEvent(candidate.Id, candidate.UserId, enqueuedTaskId); + if (!shouldParkAsFailed) { userSession.SendNotification("One of your recent scores seems to have trouble retrieving its beatmap data. This score may be missing from your profile or the leaderboards for now, but it will be fixed automatically once we can retrieve the beatmap data."); diff --git a/Sunrise.Shared.Tests/Jobs/BulkScoreProcessingJobTests.cs b/Sunrise.Shared.Tests/Jobs/BulkScoreProcessingJobTests.cs new file mode 100644 index 00000000..7638f38e --- /dev/null +++ b/Sunrise.Shared.Tests/Jobs/BulkScoreProcessingJobTests.cs @@ -0,0 +1,381 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Jobs; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Mods = osu.Shared.Mods; +using ScoreGameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using ScoreSubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Shared.Tests.Jobs; + +[Collection("Integration tests collection")] +public class BulkScoreProcessingJobTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestEnqueueByFilterShouldAddDbEntriesForMatchedScores() + { + // Arrange + var user = await CreateTestUser(); + var firstScore = await CreateTestScore(user); + var secondScore = await CreateTestScore(user); + var ignoredScore = await CreateTestScore(user); + + var scopeFactory = App.Server.Services.GetRequiredService(); + var job = new BulkScoreProcessingJob(scopeFactory); + + // Act + await job.EnqueueByFilter( + user.Id, + ScoreTaskType.Delete, + user.Id, + null, + null, + null, + null, + null, + null, + CancellationToken.None); + + // Assert + var scoreIds = new[] + { + firstScore.Id, + secondScore.Id + }; + + var tasks = await Database.DbContext.ScoreProcessingTasks + .AsNoTracking() + .Where(task => task.TaskType == ScoreTaskType.Delete + && task.ScoreId.HasValue + && scoreIds.Contains(task.ScoreId.Value)) + .ToListAsync(); + + Assert.Equal(2, tasks.Count); + Assert.All(tasks, + task => + { + Assert.Equal(ScoreTaskType.Delete, task.TaskType); + Assert.Equal((int)ScoreProcessingPriority.Low, task.Priority); + Assert.Equal(ScoreProcessingStatus.Pending, task.Status); + }); + + Assert.DoesNotContain(tasks, task => task.ScoreId == ignoredScore.Id); + } + + [Fact] + public async Task TestEnqueueByFilterShouldNotAddDbEntriesWhenNoScoresMatchFilter() + { + // Arrange + var userWithNoScores = await CreateTestUser(); + + var anotherUser = await CreateTestUser(); + _ = await CreateTestScore(anotherUser); + + var scopeFactory = App.Server.Services.GetRequiredService(); + var job = new BulkScoreProcessingJob(scopeFactory); + + // Act + await job.EnqueueByFilter( + userWithNoScores.Id, + ScoreTaskType.Recalculation, + userWithNoScores.Id, + null, + null, + null, + null, + null, + null, + CancellationToken.None); + + // Assert + var createdTasks = await Database.DbContext.ScoreProcessingTasks + .AsNoTracking() + .CountAsync(task => task.TaskType == ScoreTaskType.Recalculation); + + Assert.Equal(0, createdTasks); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterByMode() + { + // Arrange + var user = await CreateTestUser(); + var matchingScore = await CreateConfiguredScore(user, + score => score.GameMode = ScoreGameMode.Standard); + _ = await CreateConfiguredScore(user, + score => score.GameMode = ScoreGameMode.Mania); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Delete, + ScoreGameMode.Standard, + null, + null, + null, + null, + null); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Delete, [matchingScore.Id]); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterByMods() + { + // Arrange + var user = await CreateTestUser(); + var matchingScore = await CreateConfiguredScore(user, + score => score.Mods = Mods.Hidden); + _ = await CreateConfiguredScore(user, + score => score.Mods = Mods.HardRock); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Recalculation, + null, + Mods.Hidden, + null, + null, + null, + null); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Recalculation, [matchingScore.Id]); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterBySubmissionStatus() + { + // Arrange + var user = await CreateTestUser(); + var matchingScore = await CreateConfiguredScore(user, + score => score.SubmissionStatus = ScoreSubmissionStatus.Best); + _ = await CreateConfiguredScore(user, + score => score.SubmissionStatus = ScoreSubmissionStatus.Failed); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Restore, + null, + null, + ScoreSubmissionStatus.Best, + null, + null, + null); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Restore, [matchingScore.Id]); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterByBeatmapStatus() + { + // Arrange + var user = await CreateTestUser(); + var matchingScore = await CreateConfiguredScore(user, + score => score.BeatmapStatus = BeatmapStatus.Ranked); + _ = await CreateConfiguredScore(user, + score => score.BeatmapStatus = BeatmapStatus.Loved); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Delete, + null, + null, + null, + BeatmapStatus.Ranked, + null, + null); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Delete, [matchingScore.Id]); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterBySubmittedFrom() + { + // Arrange + var user = await CreateTestUser(); + var from = new DateTime(2025, 1, 10, 0, 0, 0, DateTimeKind.Utc); + + var matchingScore = await CreateConfiguredScore(user, + score => score.WhenPlayed = new DateTime(2025, 1, 10, 12, 0, 0, DateTimeKind.Utc)); + _ = await CreateConfiguredScore(user, + score => score.WhenPlayed = new DateTime(2025, 1, 9, 12, 0, 0, DateTimeKind.Utc)); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Recalculation, + null, + null, + null, + null, + from, + null); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Recalculation, [matchingScore.Id]); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterBySubmittedTo() + { + // Arrange + var user = await CreateTestUser(); + var to = new DateTime(2025, 1, 10, 23, 59, 59, DateTimeKind.Utc); + + var matchingScore = await CreateConfiguredScore(user, + score => score.WhenPlayed = new DateTime(2025, 1, 10, 12, 0, 0, DateTimeKind.Utc)); + _ = await CreateConfiguredScore(user, + score => score.WhenPlayed = new DateTime(2025, 1, 11, 12, 0, 0, DateTimeKind.Utc)); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Restore, + null, + null, + null, + null, + null, + to); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Restore, [matchingScore.Id]); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterBySubmittedFromAndSubmittedToWithSingleIntersection() + { + // Arrange + var user = await CreateTestUser(); + var from = new DateTime(2025, 1, 8, 0, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2025, 1, 15, 23, 59, 59, DateTimeKind.Utc); + + var matchesBoth = await CreateConfiguredScore(user, + score => score.WhenPlayed = new DateTime(2025, 1, 10, 12, 0, 0, DateTimeKind.Utc)); + _ = await CreateConfiguredScore(user, + score => score.WhenPlayed = new DateTime(2025, 1, 6, 12, 0, 0, DateTimeKind.Utc)); + _ = await CreateConfiguredScore(user, + score => score.WhenPlayed = new DateTime(2025, 1, 20, 12, 0, 0, DateTimeKind.Utc)); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Delete, + null, + null, + null, + null, + from, + to); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Delete, [matchesBoth.Id]); + } + + [Fact] + public async Task TestEnqueueByFilterShouldFilterByModeAndBeatmapStatusWithSingleIntersection() + { + // Arrange + var user = await CreateTestUser(); + + var matchesBoth = await CreateConfiguredScore(user, + score => + { + score.GameMode = ScoreGameMode.Standard; + score.BeatmapStatus = BeatmapStatus.Ranked; + }); + + _ = await CreateConfiguredScore(user, + score => + { + score.GameMode = ScoreGameMode.Standard; + score.BeatmapStatus = BeatmapStatus.Loved; + }); + + _ = await CreateConfiguredScore(user, + score => + { + score.GameMode = ScoreGameMode.Mania; + score.BeatmapStatus = BeatmapStatus.Ranked; + }); + + // Act + await EnqueueByFilter( + user.Id, + ScoreTaskType.Recalculation, + ScoreGameMode.Standard, + null, + null, + BeatmapStatus.Ranked, + null, + null); + + // Assert + await AssertQueuedScoreIds(ScoreTaskType.Recalculation, [matchesBoth.Id]); + } + + private async Task CreateConfiguredScore(User user, Action configure) + { + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + configure(score); + + await CreateTestScore(score); + + return score; + } + + private async Task EnqueueByFilter( + int userId, + ScoreTaskType action, + ScoreGameMode? mode, + Mods? mods, + ScoreSubmissionStatus? submissionStatus, + BeatmapStatus? beatmapStatus, + DateTime? submittedFrom, + DateTime? submittedTo) + { + var scopeFactory = App.Server.Services.GetRequiredService(); + var job = new BulkScoreProcessingJob(scopeFactory); + + await job.EnqueueByFilter( + userId, + action, + userId, + mode, + mods, + submissionStatus, + beatmapStatus, + submittedFrom, + submittedTo, + CancellationToken.None); + } + + private async Task AssertQueuedScoreIds(ScoreTaskType action, int[] expectedScoreIds) + { + var queuedScoreIds = await Database.DbContext.ScoreProcessingTasks + .AsNoTracking() + .Where(task => task.TaskType == action && task.ScoreId.HasValue) + .Select(task => task.ScoreId!.Value) + .OrderBy(id => id) + .ToListAsync(); + + Assert.Equal(expectedScoreIds.Length, queuedScoreIds.Count); + Assert.Equal(expectedScoreIds.OrderBy(id => id).ToArray(), queuedScoreIds.ToArray()); + } +} \ No newline at end of file diff --git a/Sunrise.Shared/Application/SunriseMetrics.cs b/Sunrise.Shared/Application/SunriseMetrics.cs index 604f283c..4e89a7ca 100644 --- a/Sunrise.Shared/Application/SunriseMetrics.cs +++ b/Sunrise.Shared/Application/SunriseMetrics.cs @@ -42,6 +42,10 @@ public class SunriseMetrics "score_processing_entries_total", description: "Counts individual queue-entry outcomes, tagged by outcome (success, permanent_failure, retryable_failure, unexpected)"); + public static readonly Histogram ScoreProcessingTaskDurationHistogram = SunriseMeter.CreateHistogram( + "score_processing_task_duration_seconds", + description: "Measures the duration of individual score processing tasks"); + private static readonly ObservableGauge ScoreProcessingQueueDepthPendingGauge = SunriseMeter.CreateObservableGauge( "score_processing_queue_depth_pending", () => _cachedQueueDepthByStatus?.GetValueOrDefault(ScoreProcessingStatus.Pending, 0) ?? 0, @@ -180,6 +184,7 @@ public class SunriseMetrics private static Dictionary _cachedScoresByGameMode = new(); private static Dictionary _cachedQueueDepthByStatus = new(); private static DateTime? _lastPollerRunCompletedAt; + private static double _cachedAverageTaskDurationSeconds; public SunriseMetrics() { @@ -231,6 +236,19 @@ public static void ScoreProcessingEntryCounterInc(string outcome, ScoreTaskType new KeyValuePair("error_code", code?.ToString() ?? "none")); } + public static void RecordScoreProcessingTaskDuration(double seconds, ScoreTaskType taskType) + { + ScoreProcessingTaskDurationHistogram.Record(seconds, + new KeyValuePair("task_type", taskType.ToString())); + + _cachedAverageTaskDurationSeconds = _cachedAverageTaskDurationSeconds * 0.8 + seconds * 0.2; + } + + public static double GetEstimatedAverageTaskDurationSeconds() + { + return _cachedAverageTaskDurationSeconds > 0 ? _cachedAverageTaskDurationSeconds : 0; + } + private static long GetSecondsSinceLastPollerRun() { if (_lastPollerRunCompletedAt == null) diff --git a/Sunrise.Shared/Database/Extensions/ScoreProcessingTaskQueryExtensions.cs b/Sunrise.Shared/Database/Extensions/ScoreProcessingTaskQueryExtensions.cs new file mode 100644 index 00000000..bf0a4fb0 --- /dev/null +++ b/Sunrise.Shared/Database/Extensions/ScoreProcessingTaskQueryExtensions.cs @@ -0,0 +1,12 @@ +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; + +namespace Sunrise.Shared.Database.Extensions; + +public static class ScoreProcessingTaskQueryExtensions +{ + public static IQueryable FilterInProgressTasks(this IQueryable queryable) + { + return queryable.Where(task => task.Status == ScoreProcessingStatus.Pending || task.Status == ScoreProcessingStatus.Processing); + } +} \ No newline at end of file diff --git a/Sunrise.Shared/Database/Migrations/20260610094356_AddScoreProcessingEvents.Designer.cs b/Sunrise.Shared/Database/Migrations/20260610094356_AddScoreProcessingEvents.Designer.cs new file mode 100644 index 00000000..aafc0334 --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260610094356_AddScoreProcessingEvents.Designer.cs @@ -0,0 +1,1189 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sunrise.Shared.Database; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + [DbContext(typeof(SunriseDbContext))] + [Migration("20260610094356_AddScoreProcessingEvents")] + partial class AddScoreProcessingEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Hypes") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BeatmapSetId", "Hypes"); + + b.HasIndex("BeatmapSetId", "UserId") + .IsUnique(); + + b.ToTable("beatmap_hype"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("UpdatedByUserId"); + + b.ToTable("custom_beatmap_status"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("JsonData") + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("ExecutorId"); + + b.ToTable("event_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventScoreProcessing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("JsonData") + .HasColumnType("longtext"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("TaskId") + .HasColumnType("int"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("ExecutorId"); + + b.HasIndex("ScoreId"); + + b.ToTable("event_score_processing"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Ip") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("EventType", "Ip"); + + b.HasIndex("EventType", "UserId"); + + b.ToTable("event_user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Condition") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FileId") + .HasColumnType("int"); + + b.Property("FileUrl") + .HasColumnType("longtext"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("FileId"); + + b.HasIndex("GameMode"); + + b.ToTable("medal"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.MedalFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("medal_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExecutorId"); + + b.HasIndex("UserId"); + + b.ToTable("restriction"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapId") + .HasColumnType("int"); + + b.Property("BeatmapStatus") + .HasColumnType("int"); + + b.Property("ClientTime") + .HasColumnType("datetime(6)"); + + b.Property("Count100") + .HasColumnType("int"); + + b.Property("Count300") + .HasColumnType("int"); + + b.Property("Count50") + .HasColumnType("int"); + + b.Property("CountGeki") + .HasColumnType("int"); + + b.Property("CountKatu") + .HasColumnType("int"); + + b.Property("CountMiss") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Grade") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsPassed") + .HasColumnType("tinyint(1)"); + + b.Property("IsScoreable") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("Mods") + .HasColumnType("int"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Perfect") + .HasColumnType("tinyint(1)"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("SubmissionStatus") + .HasColumnType("int"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapId"); + + b.HasIndex("UserId", "SubmissionStatus", "BeatmapStatus"); + + b.HasIndex("BeatmapId", "IsScoreable", "IsPassed", "SubmissionStatus"); + + b.HasIndex("GameMode", "SubmissionStatus", "BeatmapStatus", "WhenPlayed"); + + b.ToTable("score"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActiveScoreId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreId ELSE NULL END", true); + + b.Property("ActiveScoreSubmissionRequestId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreSubmissionRequestId ELSE NULL END", true); + + b.Property("ClaimToken") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorCode") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("longtext"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("NextRetryAt") + .HasColumnType("datetime(6)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("ScoreSubmissionRequestId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActiveScoreId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_score"); + + b.HasIndex("ActiveScoreSubmissionRequestId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_submission_request"); + + b.HasIndex("ScoreId"); + + b.HasIndex("ScoreSubmissionRequestId"); + + b.HasIndex("Status", "LeaseExpiresAt"); + + b.HasIndex("TaskType", "ScoreId"); + + b.HasIndex("Status", "Priority", "NextRetryAt"); + + b.ToTable("score_processing_task", t => + { + t.HasCheckConstraint("CK_score_processing_task_target", "((TaskType = 0 AND ScoreSubmissionRequestId IS NOT NULL AND ScoreId IS NULL) OR (TaskType <> 0 AND ScoreSubmissionRequestId IS NULL AND ScoreId IS NOT NULL))"); + }); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClientHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("ScoreSerialized") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoryboardHash") + .HasColumnType("longtext"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("UserHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("score_submission_request"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountStatus") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("smallint"); + + b.Property("DefaultGameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.Property("LastOnlineTime") + .HasColumnType("datetime(6)"); + + b.Property("Passhash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Privilege") + .HasColumnType("int"); + + b.Property("RegisterDate") + .HasColumnType("datetime(6)"); + + b.Property("SilencedUntil") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.HasKey("Id"); + + b.HasIndex("AccountStatus"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapSetId"); + + b.ToTable("user_favourite_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OwnerId") + .HasColumnType("int"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("OwnerId", "Type"); + + b.ToTable("user_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CountA") + .HasColumnType("int"); + + b.Property("CountB") + .HasColumnType("int"); + + b.Property("CountC") + .HasColumnType("int"); + + b.Property("CountD") + .HasColumnType("int"); + + b.Property("CountS") + .HasColumnType("int"); + + b.Property("CountSH") + .HasColumnType("int"); + + b.Property("CountX") + .HasColumnType("int"); + + b.Property("CountXH") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_grades"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemType") + .IsUnique(); + + b.ToTable("user_inventory_item"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MedalId") + .HasColumnType("int"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_medals"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Discord") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Interest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Occupation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Playstyle") + .HasColumnType("int"); + + b.Property("Telegram") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitch") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitter") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Website") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_metadata"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Relation") + .HasColumnType("int"); + + b.Property("TargetId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("UserId", "TargetId"); + + b.ToTable("user_relationship"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BestCountryRank") + .HasColumnType("BIGINT"); + + b.Property("BestCountryRankDate") + .HasColumnType("datetime(6)"); + + b.Property("BestGlobalRank") + .HasColumnType("BIGINT"); + + b.Property("BestGlobalRankDate") + .HasColumnType("datetime(6)"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("PlayCount") + .HasColumnType("int"); + + b.Property("PlayTime") + .HasColumnType("int"); + + b.Property("RankedScore") + .HasColumnType("BIGINT"); + + b.Property("TotalHits") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_stats"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("SnapshotsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode"); + + b.ToTable("user_stats_snapshot"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventScoreProcessing", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId"); + + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.Navigation("Executor"); + + b.Navigation("Score"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.HasOne("Sunrise.Shared.Database.Models.MedalFile", "File") + .WithMany() + .HasForeignKey("FileId"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.HasOne("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", "ScoreSubmissionRequest") + .WithMany() + .HasForeignKey("ScoreSubmissionRequestId"); + + b.Navigation("Score"); + + b.Navigation("ScoreSubmissionRequest"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserFiles") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("Inventory") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Target") + .WithMany("UserReceivedRelationships") + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserInitiatedRelationships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Target"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStatsSnapshots") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Navigation("Inventory"); + + b.Navigation("UserFiles"); + + b.Navigation("UserInitiatedRelationships"); + + b.Navigation("UserReceivedRelationships"); + + b.Navigation("UserStats"); + + b.Navigation("UserStatsSnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Sunrise.Shared/Database/Migrations/20260610094356_AddScoreProcessingEvents.cs b/Sunrise.Shared/Database/Migrations/20260610094356_AddScoreProcessingEvents.cs new file mode 100644 index 00000000..b7b1485b --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260610094356_AddScoreProcessingEvents.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + /// + public partial class AddScoreProcessingEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "event_score_processing", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + ExecutorId = table.Column(type: "int", nullable: true), + ScoreId = table.Column(type: "int", nullable: true), + TaskId = table.Column(type: "int", nullable: true), + EventType = table.Column(type: "int", nullable: false), + JsonData = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Time = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_event_score_processing", x => x.Id); + table.ForeignKey( + name: "FK_event_score_processing_score_ScoreId", + column: x => x.ScoreId, + principalTable: "score", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_event_score_processing_user_ExecutorId", + column: x => x.ExecutorId, + principalTable: "user", + principalColumn: "Id"); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_event_score_processing_EventType", + table: "event_score_processing", + column: "EventType"); + + migrationBuilder.CreateIndex( + name: "IX_event_score_processing_ExecutorId", + table: "event_score_processing", + column: "ExecutorId"); + + migrationBuilder.CreateIndex( + name: "IX_event_score_processing_ScoreId", + table: "event_score_processing", + column: "ScoreId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "event_score_processing"); + } + } +} diff --git a/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs b/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs index c758db1a..2f270ad3 100644 --- a/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs +++ b/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs @@ -115,6 +115,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("event_beatmap"); }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventScoreProcessing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("JsonData") + .HasColumnType("longtext"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("TaskId") + .HasColumnType("int"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("ExecutorId"); + + b.HasIndex("ScoreId"); + + b.ToTable("event_score_processing"); + }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => { b.Property("Id") @@ -921,6 +958,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Executor"); }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventScoreProcessing", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId"); + + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.Navigation("Executor"); + + b.Navigation("Score"); + }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => { b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") diff --git a/Sunrise.Shared/Database/Models/Events/EventScoreProcessing.cs b/Sunrise.Shared/Database/Models/Events/EventScoreProcessing.cs new file mode 100644 index 00000000..af7836f2 --- /dev/null +++ b/Sunrise.Shared/Database/Models/Events/EventScoreProcessing.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; + +namespace Sunrise.Shared.Database.Models.Events; + +[Table("event_score_processing")] +[Index(nameof(EventType))] +[Index(nameof(ScoreId))] +[Index(nameof(ExecutorId))] +public class EventScoreProcessing +{ + public int Id { get; set; } + + [ForeignKey(nameof(ExecutorId))] + public User? Executor { get; set; } + + public int? ExecutorId { get; set; } + + [ForeignKey(nameof(ScoreId))] + public Score? Score { get; set; } + + public int? ScoreId { get; set; } + public int? TaskId { get; set; } + + public ScoreProcessingEventType EventType { get; set; } + public string? JsonData { get; set; } + public DateTime Time { get; set; } = DateTime.UtcNow; + + public void SetData(T value) + { + JsonData = JsonSerializer.Serialize(value); + } + + public T? GetData() + { + if (string.IsNullOrEmpty(JsonData)) + return default; + + return JsonSerializer.Deserialize(JsonData); + } +} \ No newline at end of file diff --git a/Sunrise.Shared/Database/Repositories/EventRepository.cs b/Sunrise.Shared/Database/Repositories/EventRepository.cs index 4d6d5dd8..d0f1e259 100644 --- a/Sunrise.Shared/Database/Repositories/EventRepository.cs +++ b/Sunrise.Shared/Database/Repositories/EventRepository.cs @@ -2,8 +2,9 @@ namespace Sunrise.Shared.Database.Repositories; -public class EventRepository(UserEventService userEventService, BeatmapEventService beatmapEventService) +public class EventRepository(UserEventService userEventService, BeatmapEventService beatmapEventService, ScoreProcessingEventService scoreProcessingEventService) { public UserEventService Users { get; } = userEventService; public BeatmapEventService Beatmaps { get; } = beatmapEventService; + public ScoreProcessingEventService ScoreProcessing { get; } = scoreProcessingEventService; } \ No newline at end of file diff --git a/Sunrise.Shared/Database/Repositories/ScoreProcessingTaskRepository.cs b/Sunrise.Shared/Database/Repositories/ScoreProcessingTaskRepository.cs index c5722c6e..86ce7fc5 100644 --- a/Sunrise.Shared/Database/Repositories/ScoreProcessingTaskRepository.cs +++ b/Sunrise.Shared/Database/Repositories/ScoreProcessingTaskRepository.cs @@ -1,7 +1,9 @@ using CSharpFunctionalExtensions; using Microsoft.EntityFrameworkCore; using Sunrise.Shared.Application; +using Sunrise.Shared.Database.Extensions; using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Objects; using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Objects; @@ -32,6 +34,49 @@ public async Task TryAddQueueEntry(ScoreProcessingTask task, CancellationT } } + public async Task> BulkAddScoreTasks( + List scoreIds, + ScoreTaskType taskType, + ScoreProcessingPriority priority, + CancellationToken ct = default) + { + if (scoreIds.Count == 0) + return []; + + var existingScoreIds = await dbContext.Scores + .Where(score => scoreIds.Contains(score.Id)) + .Select(score => score.Id) + .ToListAsync(ct); + + var alreadyActiveScoreIds = await dbContext.ScoreProcessingTasks + .Where(task => task.ScoreId != null && scoreIds.Contains(task.ScoreId.Value)) + .FilterInProgressTasks() + .Select(task => task.ScoreId!.Value) + .ToListAsync(ct); + + var skip = alreadyActiveScoreIds.ToHashSet(); + var createdAt = DateTime.UtcNow; + + var tasks = existingScoreIds + .Where(scoreId => !skip.Contains(scoreId)) + .Select(scoreId => new ScoreProcessingTask + { + TaskType = taskType, + ScoreId = scoreId, + Priority = (int)priority, + CreatedAt = createdAt + }) + .ToList(); + + if (tasks.Count == 0) + return tasks; + + dbContext.ScoreProcessingTasks.AddRange(tasks); + await dbContext.SaveChangesAsync(ct); + + return tasks; + } + public async Task> ClaimPendingBatch(int limit, TimeSpan lease, CancellationToken ct = default) { var claimToken = Guid.NewGuid().ToString("N"); @@ -63,6 +108,48 @@ FROM score_processing_task .ToListAsync(ct); } + public async Task<(List, int)> GetTasks( + QueryOptions? options = null, + ScoreProcessingStatus? status = null, + ScoreTaskType? taskType = null, + int? scoreId = null, + int? taskId = null, + CancellationToken ct = default) + { + var query = dbContext.ScoreProcessingTasks.AsQueryable(); + + if (status != null) query = query.Where(t => t.Status == status); + if (taskType != null) query = query.Where(t => t.TaskType == taskType); + if (scoreId != null) query = query.Where(t => t.ScoreId == scoreId); + if (taskId != null) query = query.Where(t => t.Id == taskId); + + query = query.OrderByDescending(t => t.Id); + + var totalCount = options?.IgnoreCountQueryIfExists == true ? -1 : await query.CountAsync(ct); + + var tasks = await query + .UseQueryOptions(options) + .ToListAsync(ct); + + return (tasks, totalCount); + } + + public async Task GetTaskById(int id, QueryOptions? options = null, CancellationToken ct = default) + { + return await dbContext.ScoreProcessingTasks + .UseQueryOptions(options) + .FirstOrDefaultAsync(t => t.Id == id, ct); + } + + public async Task GetActiveTaskByScoreId(int scoreId, CancellationToken ct = default) + { + return await dbContext.ScoreProcessingTasks + .Where(t => t.ScoreId == scoreId) + .FilterInProgressTasks() + .OrderByDescending(t => t.Id) + .FirstOrDefaultAsync(ct); + } + public async Task MarkForDeletion(int taskId, CancellationToken ct = default) { await dbContext.ScoreProcessingTasks @@ -98,52 +185,63 @@ public async Task MarkAsFailed(int taskId, ScoreProcessingError error, TimeSpan public async Task> CancelTask(int taskId, CancellationToken ct = default) { - var task = await dbContext.ScoreProcessingTasks.FindAsync([taskId], ct); + var affected = await dbContext.ScoreProcessingTasks + .Where(t => t.Id == taskId && t.Status == ScoreProcessingStatus.Pending) + .ExecuteUpdateAsync(setters => setters + .SetProperty(t => t.Status, ScoreProcessingStatus.Failed) + .SetProperty(t => t.ErrorCode, ScoreProcessingErrorCode.CancelledByOperator) + .SetProperty(t => t.ErrorMessage, (string?)"Cancelled by operator") + .SetProperty(t => t.NextRetryAt, (DateTime?)null) + .SetProperty(t => t.ClaimToken, (string?)null) + .SetProperty(t => t.LeaseExpiresAt, (DateTime?)null), + ct); + + if (affected == 1) + return UnitResult.Success(); + + var task = await dbContext.ScoreProcessingTasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); + if (task == null) return UnitResult.Failure($"Score task {taskId} was not found."); if (task.Status == ScoreProcessingStatus.Processing) - return UnitResult.Failure( - $"Score task {taskId} is currently being processed and cannot be cancelled."); + return UnitResult.Failure($"Score task {taskId} is currently being processed and cannot be cancelled."); if (task.Status == ScoreProcessingStatus.Failed) return UnitResult.Failure($"Score task {taskId} has already failed; nothing to cancel."); - task.Status = ScoreProcessingStatus.Failed; - task.NextRetryAt = null; - task.ClaimToken = null; - task.LeaseExpiresAt = null; - task.ErrorCode = ScoreProcessingErrorCode.CancelledByOperator; - task.ErrorMessage = "Cancelled by operator"; - - await dbContext.SaveChangesAsync(ct); - return UnitResult.Success(); + return UnitResult.Failure($"Score task {taskId} could not be cancelled."); } - public async Task TryRequeueFailedTask(int taskId, CancellationToken ct = default) + public async Task> TryRequeueFailedTask(int taskId, CancellationToken ct = default) { - var task = await dbContext.ScoreProcessingTasks.FindAsync([taskId], ct); - if (task is not { Status: ScoreProcessingStatus.Failed }) - return false; + var affected = await dbContext.ScoreProcessingTasks + .Where(t => t.Id == taskId && t.Status == ScoreProcessingStatus.Failed) + .ExecuteUpdateAsync(setters => setters + .SetProperty(t => t.Status, ScoreProcessingStatus.Pending) + .SetProperty(t => t.RetryCount, 0) + .SetProperty(t => t.NextRetryAt, (DateTime?)null) + .SetProperty(t => t.ClaimToken, (string?)null) + .SetProperty(t => t.LeaseExpiresAt, (DateTime?)null) + .SetProperty(t => t.ErrorCode, (ScoreProcessingErrorCode?)null) + .SetProperty(t => t.ErrorMessage, (string?)null), + ct); - task.Status = ScoreProcessingStatus.Pending; - task.RetryCount = 0; - task.NextRetryAt = null; - task.ClaimToken = null; - task.LeaseExpiresAt = null; - task.ErrorCode = null; - task.ErrorMessage = null; + if (affected == 1) + return UnitResult.Success(); - try - { - await dbContext.SaveChangesAsync(ct); - return true; - } - catch (DbUpdateException ex) when (IsActiveTaskConflict(ex)) - { - await dbContext.Entry(task).ReloadAsync(ct); - return false; - } + var task = await dbContext.ScoreProcessingTasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); + + if (task == null) + return UnitResult.Failure($"Score task {taskId} was not found."); + + if (task.Status != ScoreProcessingStatus.Failed) + return UnitResult.Failure($"Score task {taskId} is not in a failed state and cannot be requeued."); + + if (task.Status == ScoreProcessingStatus.Processing) + return UnitResult.Failure($"Score task {taskId} is currently being processed and cannot be requeued."); + + return UnitResult.Failure($"Score task {taskId} could not be cancelled."); } public async Task TryRequeueFailedTasks(IEnumerable? taskIds = null, CancellationToken ct = default) @@ -171,7 +269,7 @@ public async Task TryRequeueFailedTasks(IEnumerable? taskIds = null, C foreach (var id in ids) { - if (await TryRequeueFailedTask(id, ct)) + if ((await TryRequeueFailedTask(id, ct)).IsSuccess) requeuedCount++; } @@ -206,7 +304,7 @@ private static bool IsActiveTaskConflict(DbUpdateException ex) { var message = ex.InnerException?.Message ?? ex.Message; - return message.Contains("UX_score_processing_task_active_score", StringComparison.OrdinalIgnoreCase) - || message.Contains("UX_score_processing_task_active_submission_request", StringComparison.OrdinalIgnoreCase); + return message.Contains("UX_score_processing_task_active_score", StringComparison.OrdinalIgnoreCase) + || message.Contains("UX_score_processing_task_active_submission_request", StringComparison.OrdinalIgnoreCase); } } \ No newline at end of file diff --git a/Sunrise.Shared/Database/Repositories/ScoreRepository.cs b/Sunrise.Shared/Database/Repositories/ScoreRepository.cs index d1145a70..d31394f1 100644 --- a/Sunrise.Shared/Database/Repositories/ScoreRepository.cs +++ b/Sunrise.Shared/Database/Repositories/ScoreRepository.cs @@ -11,12 +11,15 @@ using Sunrise.Shared.Database.Objects; using Sunrise.Shared.Database.Services; using Sunrise.Shared.Database.Services.Users; +using Sunrise.Shared.Enums.Beatmaps; using Sunrise.Shared.Enums.Leaderboards; +using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Extensions.Beatmaps; using Sunrise.Shared.Extensions.Scores; using Sunrise.Shared.Objects; using Sunrise.Shared.Utils; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; namespace Sunrise.Shared.Database.Repositories; @@ -287,12 +290,36 @@ public async Task> GetUserPlayHistoryScores(int userId .ToDictionaryAsync(x => x.Date, x => x.Count, ct); } - public async Task<(List, int)> GetScores(GameMode? mode = null, QueryOptions? options = null, int? startFromId = null, CancellationToken ct = default) + public async Task<(List, int)> GetScores( + GameMode? mode = null, + QueryOptions? options = null, + int? startFromId = null, + int? userId = null, + Mods? mods = null, + SubmissionStatus? submissionStatus = null, + BeatmapStatus? beatmapStatus = null, + DateTime? submittedFrom = null, + DateTime? submittedTo = null, + ScoreSortType? sort = null, + bool filterValidScores = true, + CancellationToken ct = default) { - var scoresQuery = dbContext.Scores.FilterValidScores(); - - if (mode != null) scoresQuery = scoresQuery.Where(s => s.GameMode == mode); - if (startFromId != null) scoresQuery = scoresQuery.Where(s => s.Id >= startFromId); + var scoresQuery = BuildScoresQuery(mode, + startFromId, + userId, + mods, + submissionStatus, + beatmapStatus, + submittedFrom, + submittedTo, + filterValidScores); + + scoresQuery = sort switch + { + ScoreSortType.Performance => scoresQuery.OrderByDescending(s => s.PerformancePoints).ThenByDescending(s => s.WhenPlayed), + ScoreSortType.Date => scoresQuery.OrderByDescending(s => s.WhenPlayed), + _ => scoresQuery + }; var totalCount = options?.IgnoreCountQueryIfExists == true ? -1 : await scoresQuery.CountAsync(cancellationToken: ct); @@ -303,6 +330,59 @@ public async Task> GetUserPlayHistoryScores(int userId return (scores, totalCount); } + public async Task> GetScoresForBulkProcessing( + GameMode? mode = null, + int? userId = null, + Mods? mods = null, + SubmissionStatus? submissionStatus = null, + BeatmapStatus? beatmapStatus = null, + DateTime? submittedFrom = null, + DateTime? submittedTo = null, + int? startFromId = null, + int limit = 100, + CancellationToken ct = default) + { + var scoresQuery = BuildScoresQuery(mode, + startFromId, + userId, + mods, + submissionStatus, + beatmapStatus, + submittedFrom, + submittedTo, + false); + + return await scoresQuery + .OrderBy(s => s.Id) + .Take(limit) + .ToListAsync(ct); + } + + private IQueryable BuildScoresQuery( + GameMode? mode, + int? startFromId, + int? userId, + Mods? mods, + SubmissionStatus? submissionStatus, + BeatmapStatus? beatmapStatus, + DateTime? submittedFrom, + DateTime? submittedTo, + bool filterValidScores) + { + var scoresQuery = filterValidScores ? dbContext.Scores.FilterValidScores() : dbContext.Scores.AsQueryable(); + + if (mode != null) scoresQuery = scoresQuery.Where(s => s.GameMode == mode); + if (startFromId != null) scoresQuery = scoresQuery.Where(s => s.Id >= startFromId); + if (userId != null) scoresQuery = scoresQuery.Where(s => s.UserId == userId); + if (submissionStatus != null) scoresQuery = scoresQuery.Where(s => s.SubmissionStatus == submissionStatus); + if (beatmapStatus != null) scoresQuery = scoresQuery.Where(s => s.BeatmapStatus == beatmapStatus); + if (submittedFrom != null) scoresQuery = scoresQuery.Where(s => s.WhenPlayed >= submittedFrom); + if (submittedTo != null) scoresQuery = scoresQuery.Where(s => s.WhenPlayed <= submittedTo); + if (mods != null) scoresQuery = scoresQuery.Where(s => s.Mods == EF.Constant(mods.Value)); + + return scoresQuery; + } + public async Task> EnrichScoresWithLeaderboardPosition(List scores, CancellationToken ct = default) { if (scores.Count == 0) return scores; diff --git a/Sunrise.Shared/Database/Services/Events/ScoreProcessingEventService.cs b/Sunrise.Shared/Database/Services/Events/ScoreProcessingEventService.cs new file mode 100644 index 00000000..6dab4f29 --- /dev/null +++ b/Sunrise.Shared/Database/Services/Events/ScoreProcessingEventService.cs @@ -0,0 +1,195 @@ +using CSharpFunctionalExtensions; +using Microsoft.EntityFrameworkCore; +using Sunrise.Shared.Database.Extensions; +using Sunrise.Shared.Database.Models.Events; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Objects; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Utils; + +namespace Sunrise.Shared.Database.Services.Events; + +public class ScoreProcessingEventService(SunriseDbContext dbContext) +{ + public async Task AddActionRequestedEvent(int? executorId, int scoreId, int? taskId, ScoreTaskType action, int priority, CancellationToken ct = default) + { + return await ResultUtil.TryExecuteAsync(async () => + { + var newEvent = new EventScoreProcessing + { + ExecutorId = executorId, + ScoreId = scoreId, + TaskId = taskId, + EventType = ToRequestedEventType(action) + }; + + newEvent.SetData(new + { + Action = action, + Priority = priority + }); + + dbContext.EventScoreProcessings.Add(newEvent); + await dbContext.SaveChangesAsync(ct); + }); + } + + public async Task AddActionRequestedEvents(int? executorId, IReadOnlyCollection tasks, ScoreTaskType action, + CancellationToken ct = default) + { + if (tasks.Count == 0) + return Result.Success(); + + return await ResultUtil.TryExecuteAsync(async () => + { + var events = tasks.Select(task => + { + var newEvent = new EventScoreProcessing + { + ExecutorId = executorId, + ScoreId = task.ScoreId, + TaskId = task.Id, + EventType = ToRequestedEventType(action) + }; + + newEvent.SetData(new + { + Action = action, + task.Priority + }); + + return newEvent; + }).ToList(); + + dbContext.EventScoreProcessings.AddRange(events); + await dbContext.SaveChangesAsync(ct); + }); + } + + public async Task AddCancelledEvent(int? executorId, int taskId, int? scoreId, CancellationToken ct = default) + { + return await ResultUtil.TryExecuteAsync(async () => + { + var newEvent = new EventScoreProcessing + { + ExecutorId = executorId, + ScoreId = scoreId, + TaskId = taskId, + EventType = ScoreProcessingEventType.Cancelled + }; + + newEvent.SetData(new + { + }); + + dbContext.EventScoreProcessings.Add(newEvent); + await dbContext.SaveChangesAsync(ct); + }); + } + + public async Task AddRequeuedEvent(int? executorId, int taskId, int? scoreId, ScoreProcessingErrorCode? priorErrorCode, string? priorErrorMessage, + CancellationToken ct = default) + { + return await ResultUtil.TryExecuteAsync(async () => + { + var newEvent = new EventScoreProcessing + { + ExecutorId = executorId, + ScoreId = scoreId, + TaskId = taskId, + EventType = ScoreProcessingEventType.Requeued + }; + + newEvent.SetData(new + { + PriorErrorCode = priorErrorCode, + PriorErrorMessage = priorErrorMessage + }); + + dbContext.EventScoreProcessings.Add(newEvent); + await dbContext.SaveChangesAsync(ct); + }); + } + + public async Task AddBulkRequestedEvent(int? executorId, ScoreTaskType action, object filterSummary, int matched, int queued, int skipped, + CancellationToken ct = default) + { + return await ResultUtil.TryExecuteAsync(async () => + { + var newEvent = new EventScoreProcessing + { + ExecutorId = executorId, + EventType = ScoreProcessingEventType.BulkRequested + }; + + newEvent.SetData(new + { + Action = action, + Filters = filterSummary, + Matched = matched, + Queued = queued, + Skipped = skipped + }); + + dbContext.EventScoreProcessings.Add(newEvent); + await dbContext.SaveChangesAsync(ct); + }); + } + + public async Task AddSubmissionEnqueuedEvent(int? scoreSubmissionRequestId, int? submitterUserId, int? taskId, CancellationToken ct = default) + { + return await ResultUtil.TryExecuteAsync(async () => + { + var newEvent = new EventScoreProcessing + { + ExecutorId = null, + TaskId = taskId, + EventType = ScoreProcessingEventType.SubmissionEnqueued + }; + + newEvent.SetData(new + { + ScoreSubmissionRequestId = scoreSubmissionRequestId, + SubmitterUserId = submitterUserId + }); + + dbContext.EventScoreProcessings.Add(newEvent); + await dbContext.SaveChangesAsync(ct); + }); + } + + public async Task<(List, int)> GetEvents(QueryOptions? options = null, List? types = null, int? scoreId = null, + CancellationToken ct = default) + { + var query = dbContext.EventScoreProcessings.AsQueryable(); + + if (types is { Count: > 0 }) + query = query.Where(e => types.Contains(e.EventType)); + + if (scoreId.HasValue) + query = query.Where(e => e.ScoreId == scoreId.Value); + + query = query.OrderByDescending(e => e.Id); + + var totalCount = options?.IgnoreCountQueryIfExists == true ? -1 : await query.CountAsync(ct); + + var events = await query + .Include(e => e.Executor) + .UseQueryOptions(options) + .ToListAsync(ct); + + return (events, totalCount); + } + + private static ScoreProcessingEventType ToRequestedEventType(ScoreTaskType action) + { + return action switch + { + ScoreTaskType.Recalculation => ScoreProcessingEventType.RecalculationRequested, + ScoreTaskType.Restore => ScoreProcessingEventType.RestoreRequested, + ScoreTaskType.Delete => ScoreProcessingEventType.DeleteRequested, + ScoreTaskType.Submission => ScoreProcessingEventType.SubmissionEnqueued, + _ => throw new ArgumentOutOfRangeException(nameof(action), action, "Unsupported score processing action for an audit event.") + }; + } +} \ No newline at end of file diff --git a/Sunrise.Shared/Database/SunriseDbContext.cs b/Sunrise.Shared/Database/SunriseDbContext.cs index 44c51289..54483866 100644 --- a/Sunrise.Shared/Database/SunriseDbContext.cs +++ b/Sunrise.Shared/Database/SunriseDbContext.cs @@ -36,6 +36,7 @@ public SunriseDbContext(DbContextOptions options) : base(optio public DbSet EventBeatmaps { get; set; } public DbSet EventUsers { get; set; } + public DbSet EventScoreProcessings { get; set; } public DbSet Restrictions { get; set; } public DbSet Scores { get; set; } diff --git a/Sunrise.Shared/Enums/Scores/ScoreProcessingEventType.cs b/Sunrise.Shared/Enums/Scores/ScoreProcessingEventType.cs new file mode 100644 index 00000000..6a0b6e4d --- /dev/null +++ b/Sunrise.Shared/Enums/Scores/ScoreProcessingEventType.cs @@ -0,0 +1,12 @@ +namespace Sunrise.Shared.Enums.Scores; + +public enum ScoreProcessingEventType +{ + RecalculationRequested = 0, + RestoreRequested = 1, + DeleteRequested = 2, + SubmissionEnqueued = 3, + Cancelled = 4, + Requeued = 5, + BulkRequested = 6 +} diff --git a/Sunrise.Shared/Enums/Scores/ScoreSortType.cs b/Sunrise.Shared/Enums/Scores/ScoreSortType.cs new file mode 100644 index 00000000..124cd390 --- /dev/null +++ b/Sunrise.Shared/Enums/Scores/ScoreSortType.cs @@ -0,0 +1,7 @@ +namespace Sunrise.Shared.Enums.Scores; + +public enum ScoreSortType +{ + Date = 0, + Performance = 1 +} diff --git a/Sunrise.Shared/Jobs/BulkScoreProcessingJob.cs b/Sunrise.Shared/Jobs/BulkScoreProcessingJob.cs new file mode 100644 index 00000000..f4edc5b4 --- /dev/null +++ b/Sunrise.Shared/Jobs/BulkScoreProcessingJob.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using osu.Shared; +using Sunrise.Shared.Database; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Services; +using BeatmapStatus = Sunrise.Shared.Enums.Beatmaps.BeatmapStatus; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Shared.Jobs; + +public class BulkScoreProcessingJob(IServiceScopeFactory scopeFactory) +{ + private const int PageSize = 100; + + public async Task EnqueueByFilter( + int? executorId, + ScoreTaskType action, + int userId, + GameMode? mode, + Mods? mods, + SubmissionStatus? submissionStatus, + BeatmapStatus? beatmapStatus, + DateTime? submittedFrom, + DateTime? submittedTo, + CancellationToken ct) + { + await BackgroundTaskService.ExecuteBackgroundTask(async () => + { + var matched = 0; + var queued = 0; + var skipped = 0; + int? lastScoreId = null; + + while (true) + { + using var scope = scopeFactory.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var pageScores = await database.Scores.GetScoresForBulkProcessing( + mode, + userId, + mods, + submissionStatus, + beatmapStatus, + submittedFrom, + submittedTo, + lastScoreId != null ? lastScoreId + 1 : null, + PageSize, + ct); + + if (pageScores.Count == 0) + break; + + var scoreIds = pageScores.Select(score => score.Id).ToList(); + var queuedTasks = await database.ScoreProcessingTasks.BulkAddScoreTasks(scoreIds, action, ScoreProcessingPriority.Low, ct); + lastScoreId = scoreIds[^1]; + + matched += pageScores.Count; + queued += queuedTasks.Count; + skipped += pageScores.Count - queuedTasks.Count; + + ct.ThrowIfCancellationRequested(); + + if (pageScores.Count < PageSize) + break; + } + + using var summaryScope = scopeFactory.CreateScope(); + var summaryDatabase = summaryScope.ServiceProvider.GetRequiredService(); + + await summaryDatabase.Events.ScoreProcessing.AddBulkRequestedEvent( + executorId, + action, + new + { + UserId = userId, + Mode = mode, + Mods = mods, + SubmissionStatus = submissionStatus, + BeatmapStatus = beatmapStatus, + From = submittedFrom, + To = submittedTo + }, + matched, + queued, + skipped, + ct); + }); + } +} \ No newline at end of file