From 5a00a2478e8add0075a4a04c26c1bf413e04bc3d Mon Sep 17 00:00:00 2001 From: NotS1mp Date: Thu, 14 May 2026 18:24:20 +0200 Subject: [PATCH] Update delete workflows --- Application/Events/DeletePlayListEvent.cs | 6 + Application/Events/RemoveFileEvent.cs | 6 - .../Handlers/DeletePlayListCommandHandler.cs | 104 +--- .../Handler/DeleteTrackCommandHandler.cs | 3 +- Domain/Entities/LifeCycleStatus.cs | 8 + Domain/Entities/PlayList.cs | 3 + Domain/Entities/Track.cs | 3 + .../Files/RemoveFileFromBucketActivity.cs | 12 + .../PlayLists/DeletePlayListFromDbActivity.cs | 35 ++ .../DeletePlayListFromDbArguments.cs | 5 + .../MarkPlayListAsRemovingActivity.cs | 36 ++ .../MarkPlayListAsRemovingArguments.cs | 5 + .../Activities/Tracks/DeleteTrackActivity.cs | 115 ---- .../Activities/Tracks/DeleteTrackArguments.cs | 6 - .../Tracks/DeleteTrackFromDbActivity.cs | 35 ++ .../Tracks/DeleteTrackFromDbArguments.cs | 5 + .../Tracks/MarkTrackAsRemovingActivity.cs | 36 ++ .../Tracks/MarkTrackAsRemovingArguments.cs | 5 + .../Consumers/DeletePlayListConsumer.cs | 27 + .../Consumers/DeleteTrackConsumer.cs | 4 +- .../Consumers/RemoveFileConsumer.cs | 40 -- ...TransitDependencyInjection.Registration.cs | 33 +- .../MassTransit/RoutingSlip/ActivityNames.cs | 4 + .../DeletePlayListRoutingSlipBuilder.cs | 94 ++++ .../Builders/DeleteTrackRoutingSlipBuilder.cs | 84 ++- ...60514161518_AddLifeCycleStatus.Designer.cs | 518 ++++++++++++++++++ .../20260514161518_AddLifeCycleStatus.cs | 40 ++ .../Migrations/DatabaseModelSnapshot.cs | 6 + docs/changes-summary.md | 35 +- docs/delete-workflows-plan.md | 78 ++- 30 files changed, 1098 insertions(+), 293 deletions(-) create mode 100644 Application/Events/DeletePlayListEvent.cs delete mode 100644 Application/Events/RemoveFileEvent.cs create mode 100644 Domain/Entities/LifeCycleStatus.cs create mode 100644 Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs create mode 100644 Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbArguments.cs create mode 100644 Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs create mode 100644 Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingArguments.cs delete mode 100644 Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs delete mode 100644 Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs create mode 100644 Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs create mode 100644 Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbArguments.cs create mode 100644 Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs create mode 100644 Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingArguments.cs create mode 100644 Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs delete mode 100644 Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs create mode 100644 Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs create mode 100644 Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.Designer.cs create mode 100644 Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.cs diff --git a/Application/Events/DeletePlayListEvent.cs b/Application/Events/DeletePlayListEvent.cs new file mode 100644 index 0000000..0c068df --- /dev/null +++ b/Application/Events/DeletePlayListEvent.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Events +{ + public record DeletePlayListEvent( + Guid PlayListId, + Guid UserId); +} diff --git a/Application/Events/RemoveFileEvent.cs b/Application/Events/RemoveFileEvent.cs deleted file mode 100644 index 4378be5..0000000 --- a/Application/Events/RemoveFileEvent.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Application.Events -{ - public record RemoveFileEvent( - string Bucket, - string Key); -} \ No newline at end of file diff --git a/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs b/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs index 08d3774..e7be170 100644 --- a/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs +++ b/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs @@ -2,15 +2,17 @@ using Ardalis.Result; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; using Musify.Application.Abstractions.Infrastructure; using Musify.Application.Events; using Musify.Application.PlayLists.Commands; +using Musify.Domain.Entities; namespace Musify.Application.PlayLists.Handlers { - public class DeletePlayListCommandHandler(IDatabase database, IEventBus eventBus, - ApplicationStorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration, ILogger logger) + public class DeletePlayListCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) : ICommandHandler { public async ValueTask Handle(DeletePlayListCommand request, CancellationToken cancellationToken) @@ -28,101 +30,31 @@ namespace Musify.Application.PlayLists.Handlers return Result.Unauthorized(); } - var originalPictureName = playList.OriginalPictureName; - var smallPictureName = playList.SmallPictureName; - var mediumPictureName = playList.MediumPictureName; - var largePictureName = playList.LargePictureName; + playList.LifeCycleStatus = LifeCycleStatus.Removing; - var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName); - var smallPictureKey = playListConfiguration.Routes.BuildSmallPicturePath(playList.SmallPictureName); - var mediumPictureKey = playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName); - var largePictureKey = playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName); - - var publishResult = await PublishRemoveFileEventsAsync( - playList.Id, - originalPictureName, - smallPictureName, - mediumPictureName, - largePictureName, - originalPictureKey, - smallPictureKey, - mediumPictureKey, - largePictureKey, - playListConfiguration.Routes, - cancellationToken); - - if (!publishResult.IsSuccess) - return publishResult; + try + { + await eventBus.PublishAsync( + new DeletePlayListEvent(playList.Id, request.UserId), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId); + return Result.Error($"Failed to delete playlist {request.PlayListId}"); + } try { - database.PlayLists.Remove(playList); await database.SaveChangesAsync(cancellationToken); } catch (Exception exception) { - logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId); + logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId); return Result.Error($"Failed to delete playlist {request.PlayListId}"); } return Result.NoContent(); } - - async Task PublishRemoveFileEventsAsync( - Guid playListId, - string originalPictureName, - string smallPictureName, - string mediumPictureName, - string largePictureName, - string originalPictureKey, - string smallPictureKey, - string mediumPictureKey, - string largePictureKey, - PlayListRoutes playListRoutes, - CancellationToken cancellationToken) - { - if (originalPictureName != playListRoutes.PresetOriginalPicture) - { - var result = await PublishRemoveFileEventAsync(playListId, originalPictureKey, cancellationToken); - if (!result.IsSuccess) - return result; - } - if (smallPictureName != playListRoutes.PresetSmallPicture) - { - var result = await PublishRemoveFileEventAsync(playListId, smallPictureKey, cancellationToken); - if (!result.IsSuccess) - return result; - } - if (mediumPictureName != playListRoutes.PresetMediumPicture) - { - var result = await PublishRemoveFileEventAsync(playListId, mediumPictureKey, cancellationToken); - if (!result.IsSuccess) - return result; - } - if (largePictureName != playListRoutes.PresetLargePicture) - { - var result = await PublishRemoveFileEventAsync(playListId, largePictureKey, cancellationToken); - if (!result.IsSuccess) - return result; - } - - return Result.Success(); - } - - async Task PublishRemoveFileEventAsync(Guid playListId, string key, CancellationToken cancellationToken) - { - try - { - await eventBus.PublishAsync(new RemoveFileEvent( - storageConfiguration.Bucket, - key), cancellationToken); - return Result.Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish remove file event for playlist {PlayListId} with key {Key}", playListId, key); - return Result.Error($"Failed to publish remove file event for playlist {playListId}"); - } - } } } \ No newline at end of file diff --git a/Application/Tracks/Handler/DeleteTrackCommandHandler.cs b/Application/Tracks/Handler/DeleteTrackCommandHandler.cs index bd07a1d..069254a 100644 --- a/Application/Tracks/Handler/DeleteTrackCommandHandler.cs +++ b/Application/Tracks/Handler/DeleteTrackCommandHandler.cs @@ -40,8 +40,7 @@ namespace Musify.Application.Tracks.Handler return Result.Conflict("Track is currently being processed and cannot be deleted"); } - track.PicturesProcessingStatus = ProcessingStatus.Processing; - track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing; + track.LifeCycleStatus = LifeCycleStatus.Removing; try { diff --git a/Domain/Entities/LifeCycleStatus.cs b/Domain/Entities/LifeCycleStatus.cs new file mode 100644 index 0000000..bd4c7dc --- /dev/null +++ b/Domain/Entities/LifeCycleStatus.cs @@ -0,0 +1,8 @@ +namespace Musify.Domain.Entities +{ + public enum LifeCycleStatus + { + Active = 0, + Removing = 1 + } +} diff --git a/Domain/Entities/PlayList.cs b/Domain/Entities/PlayList.cs index 3d73d19..e01985f 100644 --- a/Domain/Entities/PlayList.cs +++ b/Domain/Entities/PlayList.cs @@ -41,6 +41,9 @@ namespace Musify.Domain.Entities [MaxLength(64)] public required string LargePictureName { get; set; } + [Required] + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] public DateTime CreatedDate { get; set; } = DateTime.UtcNow; diff --git a/Domain/Entities/Track.cs b/Domain/Entities/Track.cs index 82fa295..182b56b 100644 --- a/Domain/Entities/Track.cs +++ b/Domain/Entities/Track.cs @@ -44,6 +44,9 @@ namespace Musify.Domain.Entities [Required] public ProcessingStatus AudioTranscodeProcessingStatus { get; set; } = ProcessingStatus.Pending; + [Required] + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] public int RetryCount { get; set; } = 0; diff --git a/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs index 45125d8..a28baa9 100644 --- a/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -1,7 +1,9 @@ using MassTransit; +using Amazon.S3; using Microsoft.Extensions.Logging; using Musify.Application.Abstractions.Infrastructure; using Musify.Infrastructure.MassTransit.Activities.Arguments; +using System.Net; namespace Musify.Infrastructure.MassTransit.Activities.Files { @@ -22,6 +24,16 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files executeContext.CancellationToken); return executeContext.Completed(); } + catch (AmazonS3Exception exception) when ( + exception.StatusCode == HttpStatusCode.NotFound + || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation( + "Key {Key} not found in bucket {Bucket}, skipping", + executeContext.Arguments.Key, + executeContext.Arguments.Bucket); + return executeContext.Completed(); + } catch (Exception exception) { logger.LogError(exception, "Failed to remove {Key} from bucket {Bucket}", diff --git a/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs b/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs new file mode 100644 index 0000000..e929f29 --- /dev/null +++ b/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs @@ -0,0 +1,35 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; + +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +{ + internal class DeletePlayListFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "delete-playList-from-db"; + + public async Task Execute(ExecuteContext executeContext) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); + + if (playList is null) + { + logger.LogInformation( + "PlayList {PlayListId} not found while deleting from DB, skipping", + executeContext.Arguments.PlayListId); + return executeContext.Completed(); + } + + database.PlayLists.Remove(playList); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted playlist {PlayListId} from DB", playList.Id); + return executeContext.Completed(); + } + } +} diff --git a/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbArguments.cs b/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbArguments.cs new file mode 100644 index 0000000..9890c26 --- /dev/null +++ b/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +{ + internal record DeletePlayListFromDbArguments( + Guid PlayListId); +} diff --git a/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs b/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs new file mode 100644 index 0000000..67d09ad --- /dev/null +++ b/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs @@ -0,0 +1,36 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Domain.Entities; + +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +{ + internal class MarkPlayListAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "mark-playList-as-removing"; + + public async Task Execute(ExecuteContext executeContext) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); + + if (playList is null) + { + logger.LogInformation( + "PlayList {PlayListId} not found while marking as removing, skipping", + executeContext.Arguments.PlayListId); + return executeContext.Completed(); + } + + playList.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked playlist {PlayListId} as removing", playList.Id); + return executeContext.Completed(); + } + } +} diff --git a/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingArguments.cs b/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingArguments.cs new file mode 100644 index 0000000..fefbf99 --- /dev/null +++ b/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +{ + internal record MarkPlayListAsRemovingArguments( + Guid PlayListId); +} diff --git a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs deleted file mode 100644 index 2e4bf08..0000000 --- a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs +++ /dev/null @@ -1,115 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Abstractions.Infrastructure; -using Musify.Domain.Entities; - -namespace Musify.Infrastructure.MassTransit.Activities.Tracks -{ - internal class DeleteTrackActivity( - IDatabase database, - IStorageService storageService, - TrackConfiguration trackConfiguration, - ApplicationStorageConfiguration storageConfiguration, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "delete-track"; - - public async Task Execute(ExecuteContext executeContext) - { - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track {TrackId} for deletion", executeContext.Arguments.TrackId); - throw; - } - - try - { - await RemovePicturesAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken); - track.PicturesProcessingStatus = ProcessingStatus.Completed; - await database.SaveChangesAsync(executeContext.CancellationToken); - - await RemoveAudiosAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken); - track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed; - await database.SaveChangesAsync(executeContext.CancellationToken); - - database.Tracks.Remove(track); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Deleted track {TrackId}", track.Id); - return executeContext.Completed(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to delete track {TrackId}", executeContext.Arguments.TrackId); - - try - { - track.PicturesProcessingStatus = ProcessingStatus.Failed; - track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update processing statuses to Failed for track {TrackId}", executeContext.Arguments.TrackId); - } - - throw; - } - } - - async Task RemovePicturesAsync(Track track, Guid userId, CancellationToken cancellationToken) - { - await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName), cancellationToken); - - if (!track.IsPicturesProcessed) - { - logger.LogInformation( - "Track {TrackId} pictures are not fully processed, skipping removal of small, medium, and large pictures", - track.Id); - return; - } - - await RemoveFileAsync(trackConfiguration.Routes.BuildSmallPicturePath(track.SmallPictureName), cancellationToken); - await RemoveFileAsync(trackConfiguration.Routes.BuildMediumPicturePath(track.MediumPictureName), cancellationToken); - await RemoveFileAsync(trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName), cancellationToken); - } - - async Task RemoveAudiosAsync(Track track, Guid userId, CancellationToken cancellationToken) - { - await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName), cancellationToken); - - if (!track.IsAudioProcessed) - { - logger.LogInformation( - "Track {TrackId} audio is not fully processed, skipping removal of processed audio", - track.Id); - return; - } - - await RemoveFileAsync(trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName), cancellationToken); - } - - async Task RemoveFileAsync(string key, CancellationToken cancellationToken) - { - try - { - logger.LogDebug("Removing file {Key} from bucket {Bucket}", key, storageConfiguration.Bucket); - await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to remove file {Key} from bucket {Bucket}", key, storageConfiguration.Bucket); - throw; - } - } - } -} diff --git a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs deleted file mode 100644 index 0caacb8..0000000 --- a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackArguments.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Activities.Tracks -{ - internal record DeleteTrackArguments( - Guid TrackId, - Guid UserId); -} diff --git a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs new file mode 100644 index 0000000..f1a5d0d --- /dev/null +++ b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs @@ -0,0 +1,35 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; + +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal class DeleteTrackFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "delete-track-from-db"; + + public async Task Execute(ExecuteContext executeContext) + { + var track = await database.Tracks + .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + + if (track is null) + { + logger.LogInformation( + "Track {TrackId} not found while deleting from DB, skipping", + executeContext.Arguments.TrackId); + return executeContext.Completed(); + } + + database.Tracks.Remove(track); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted track {TrackId} from DB", track.Id); + return executeContext.Completed(); + } + } +} diff --git a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbArguments.cs b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbArguments.cs new file mode 100644 index 0000000..be5b3ae --- /dev/null +++ b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal record DeleteTrackFromDbArguments( + Guid TrackId); +} diff --git a/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs b/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs new file mode 100644 index 0000000..4290155 --- /dev/null +++ b/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs @@ -0,0 +1,36 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Domain.Entities; + +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal class MarkTrackAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "mark-track-as-removing"; + + public async Task Execute(ExecuteContext executeContext) + { + var track = await database.Tracks + .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + + if (track is null) + { + logger.LogInformation( + "Track {TrackId} not found while marking as removing, skipping", + executeContext.Arguments.TrackId); + return executeContext.Completed(); + } + + track.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked track {TrackId} as removing", track.Id); + return executeContext.Completed(); + } + } +} diff --git a/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingArguments.cs b/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingArguments.cs new file mode 100644 index 0000000..70625ca --- /dev/null +++ b/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal record MarkTrackAsRemovingArguments( + Guid TrackId); +} diff --git a/Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs new file mode 100644 index 0000000..8c19ed5 --- /dev/null +++ b/Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs @@ -0,0 +1,27 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class DeletePlayListConsumer( + IBus bus, + DeletePlayListRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "delete-playList"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = (await routingSlipBuilder + .BuildAsync( + consumeContext.Message.PlayListId, + consumeContext.Message.UserId, + consumeContext.CorrelationId, + consumeContext.CancellationToken)) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs index b5c3ee1..fbdc9a8 100644 --- a/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs +++ b/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -13,8 +13,8 @@ namespace Musify.Infrastructure.MassTransit.Consumers public async Task Consume(ConsumeContext consumeContext) { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId) + var routingSlip = (await routingSlipBuilder + .BuildAsync(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId, consumeContext.CancellationToken)) .Build(); await bus.Execute(routingSlip); diff --git a/Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs b/Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs deleted file mode 100644 index e56b117..0000000 --- a/Infrastructure/MassTransit/Consumers/RemoveFileConsumer.cs +++ /dev/null @@ -1,40 +0,0 @@ -using MassTransit; -using Microsoft.Extensions.Logging; -using Musify.Application.Abstractions.Infrastructure; -using Musify.Application.Events; - -namespace Musify.Infrastructure.MassTransit.Consumers -{ - public class RemoveFileConsumer( - IStorageService storageService, - ILogger logger) - : IConsumer - { - public const string QueueName = "remove-file"; - - public async Task Consume(ConsumeContext consumeContext) - { - try - { - await storageService.RemoveFileAsync( - consumeContext.Message.Bucket, - consumeContext.Message.Key, - consumeContext.CancellationToken); - - logger.LogInformation( - "Removed file {Key} from bucket {Bucket}", - consumeContext.Message.Key, - consumeContext.Message.Bucket); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to remove file {Key} from bucket {Bucket}", - consumeContext.Message.Key, - consumeContext.Message.Bucket); - throw; - } - } - } -} diff --git a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index f1620cd..860efac 100644 --- a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -6,6 +6,7 @@ using Musify.Infrastructure.MassTransit.Activities.Arguments; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.Logs; +using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; @@ -21,6 +22,7 @@ namespace Musify.Infrastructure.MassTransit services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) @@ -30,12 +32,15 @@ namespace Musify.Infrastructure.MassTransit options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); - options.AddConsumer(); + options.AddConsumer(); options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); - options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddActivity(); options.AddActivity(); @@ -75,10 +80,10 @@ namespace Musify.Infrastructure.MassTransit busRegistrationContext, DeleteTrackConsumer.QueueName); - ConfigureConsumerEndpoint( + ConfigureConsumerEndpoint( busFactoryConfigurator, busRegistrationContext, - RemoveFileConsumer.QueueName); + DeletePlayListConsumer.QueueName); ConfigureConsumerEndpoint( busFactoryConfigurator, @@ -100,6 +105,26 @@ namespace Musify.Infrastructure.MassTransit busRegistrationContext, GeneratePictureWorkflowPathsActivity.ExecuteEndpointName); + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + MarkTrackAsRemovingActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + DeleteTrackFromDbActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + MarkPlayListAsRemovingActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + DeletePlayListFromDbActivity.ExecuteEndpointName); + ConfigureActivityEndpoint( busFactoryConfigurator, busRegistrationContext, diff --git a/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index b5d9ff5..71d037d 100644 --- a/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -3,6 +3,10 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal static class ActivityNames { internal const string DeleteTrack = "DeleteTrack"; + internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving"; + internal const string DeleteTrackFromDb = "DeleteTrackFromDb"; + internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; + internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string DownloadFile = "DownloadFile"; diff --git a/Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs new file mode 100644 index 0000000..232744c --- /dev/null +++ b/Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs @@ -0,0 +1,94 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Application.Configuration; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Arguments; +using Musify.Infrastructure.MassTransit.Activities.PlayLists; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class DeletePlayListRoutingSlipBuilder( + IDatabase database, + PlayListConfiguration playListConfiguration, + ApplicationStorageConfiguration storageConfiguration) + { + public async Task BuildAsync( + Guid playListId, + Guid userId, + Guid? correlationId, + CancellationToken cancellationToken) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + var playList = await database.PlayLists + .AsNoTracking() + .SingleOrDefaultAsync(p => p.Id == playListId, cancellationToken); + + routingSlipBuilder.AddActivity( + ActivityNames.MarkPlayListAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), + new MarkPlayListAsRemovingArguments(playListId)); + + if (playList is not null) + { + var bucket = storageConfiguration.Bucket; + var routes = playListConfiguration.Routes; + + if (playList.OriginalPictureName != routes.PresetOriginalPicture) + { + routingSlipBuilder.AddActivity( + "RemovePlayListOriginalPicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildOriginalPicturePath(userId, playList.OriginalPictureName))); + } + + if (playList.SmallPictureName != routes.PresetSmallPicture) + { + routingSlipBuilder.AddActivity( + "RemovePlayListSmallPicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildSmallPicturePath(playList.SmallPictureName))); + } + + if (playList.MediumPictureName != routes.PresetMediumPicture) + { + routingSlipBuilder.AddActivity( + "RemovePlayListMediumPicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildMediumPicturePath(playList.MediumPictureName))); + } + + if (playList.LargePictureName != routes.PresetLargePicture) + { + routingSlipBuilder.AddActivity( + "RemovePlayListLargePicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildLargePicturePath(playList.LargePictureName))); + } + } + + routingSlipBuilder.AddActivity( + ActivityNames.DeletePlayListFromDb, + EndpointHelper.BuildExecuteActivityUri(DeletePlayListFromDbActivity.ExecuteEndpointName), + new DeletePlayListFromDbArguments(playListId)); + + return routingSlipBuilder; + } + } +} diff --git a/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs b/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs index 942254d..6da734e 100644 --- a/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs +++ b/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs @@ -1,13 +1,25 @@ using MassTransit; using MassTransit.Courier.Contracts; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Application.Configuration; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Arguments; using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Consumers; namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public class DeleteTrackRoutingSlipBuilder + public class DeleteTrackRoutingSlipBuilder( + IDatabase database, + TrackConfiguration trackConfiguration, + ApplicationStorageConfiguration storageConfiguration) { - public RoutingSlipBuilder Build(Guid trackId, Guid userId, Guid? correlationId) + public async Task BuildAsync( + Guid trackId, + Guid userId, + Guid? correlationId, + CancellationToken cancellationToken) { var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); @@ -16,10 +28,72 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + var track = await database.Tracks + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + routingSlipBuilder.AddActivity( - ActivityNames.DeleteTrack, - EndpointHelper.BuildExecuteActivityUri(DeleteTrackActivity.ExecuteEndpointName), - new DeleteTrackArguments(trackId, userId)); + ActivityNames.MarkTrackAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkTrackAsRemovingActivity.ExecuteEndpointName), + new MarkTrackAsRemovingArguments(trackId)); + + if (track is not null) + { + var bucket = storageConfiguration.Bucket; + + routingSlipBuilder.AddActivity( + "RemoveTrackOriginalPicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName))); + + if (track.IsPicturesProcessed) + { + routingSlipBuilder.AddActivity( + "RemoveTrackSmallPicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildSmallPicturePath(track.SmallPictureName))); + + routingSlipBuilder.AddActivity( + "RemoveTrackMediumPicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildMediumPicturePath(track.MediumPictureName))); + + routingSlipBuilder.AddActivity( + "RemoveTrackLargePicture", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName))); + } + + routingSlipBuilder.AddActivity( + "RemoveTrackOriginalAudio", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName))); + + if (track.IsAudioProcessed) + { + routingSlipBuilder.AddActivity( + "RemoveTrackProcessedAudio", + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName))); + } + } + + routingSlipBuilder.AddActivity( + ActivityNames.DeleteTrackFromDb, + EndpointHelper.BuildExecuteActivityUri(DeleteTrackFromDbActivity.ExecuteEndpointName), + new DeleteTrackFromDbArguments(trackId)); return routingSlipBuilder; } diff --git a/Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.Designer.cs b/Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.Designer.cs new file mode 100644 index 0000000..98c3d87 --- /dev/null +++ b/Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.Designer.cs @@ -0,0 +1,518 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260514161518_AddLifeCycleStatus")] + partial class AddLifeCycleStatus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.cs b/Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.cs new file mode 100644 index 0000000..68a1b0c --- /dev/null +++ b/Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddLifeCycleStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LifeCycleStatus", + table: "Tracks", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LifeCycleStatus", + table: "PlayLists", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LifeCycleStatus", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "LifeCycleStatus", + table: "PlayLists"); + } + } +} diff --git a/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 12b1182..30ec587 100644 --- a/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -209,6 +209,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(64) .HasColumnType("character varying(64)"); + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + b.Property("MediumPictureName") .IsRequired() .HasMaxLength(64) @@ -297,6 +300,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("LastRetryAt") .HasColumnType("timestamp with time zone"); + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + b.Property("MediumPictureName") .HasMaxLength(64) .HasColumnType("character varying(64)"); diff --git a/docs/changes-summary.md b/docs/changes-summary.md index c6c57bc..0dbe664 100644 --- a/docs/changes-summary.md +++ b/docs/changes-summary.md @@ -97,8 +97,7 @@ Archivos clave: - Se añadieron overloads de rutas para construir keys con `userId`. - Los handlers de request presigned URL ahora generan keys usando el prefijo con `userId`. - Los eventos de workflows (sourceKey) se publican con la key prefijada. -- El borrado de playlist borra la key original prefijada. -- El borrado de track (`DeleteTrackActivity`) borra originales usando el `userId` presente en `DeleteTrackArguments`. +- El borrado de playlist y track usan keys prefijadas dentro de sus workflows de delete. Archivos: - `Application/Configuration/PlayListConfiguration.cs` @@ -109,7 +108,7 @@ Archivos: - `Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs` - `Application/Tracks/Handler/CreateTrackCommandHandler.cs` - `Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs` -- `Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs` + --- @@ -163,3 +162,33 @@ Archivo: ## 10) Estado final - `dotnet build MusifyBackend.slnx -c Release` ✅ + +--- + +## 11) Deletes unificados con Workflows (RoutingSlip) + +**Objetivo**: homogeneizar deletes de Track/PlayList usando Courier RoutingSlip con actividades granulares e idempotentes. + +### Cambios +- Se añadió `LifeCycleStatus` (`Active`/`Removing`) a `Track` y `PlayList` y se generó migración. +- Track delete: + - Se eliminó la activity monolítica y el slip ahora encadena: marcar como removing → borrar N ficheros (condicional) → borrar en DB. +- PlayList delete: + - Se añadió `DeletePlayListEvent` + consumer + routing slip builder + activities para marcar como removing, borrar pictures (si no son preset) y borrar en DB. +- Se endureció la idempotencia de borrado en S3: si una key no existe, se loguea y el paso se considera completado. +- Se eliminaron `RemoveFileEvent`/`RemoveFileConsumer` por quedar sin usos. + +Archivos clave: +- `Domain/Entities/LifeCycleStatus.cs` +- `Domain/Entities/Track.cs` +- `Domain/Entities/PlayList.cs` +- `Application/Events/DeletePlayListEvent.cs` +- `Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs` +- `Application/Tracks/Handler/DeleteTrackCommandHandler.cs` +- `Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs` +- `Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs` +- `Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs` +- `Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs` +- `Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs` +- `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs` +- `Infrastructure/Persistence/Migrations/20260514161518_AddLifeCycleStatus.cs` diff --git a/docs/delete-workflows-plan.md b/docs/delete-workflows-plan.md index 5b92b0c..22205d7 100644 --- a/docs/delete-workflows-plan.md +++ b/docs/delete-workflows-plan.md @@ -4,9 +4,9 @@ Actualmente hay dos problemas de diseño en los deletes: -**PlayLists:** el borrado dispara múltiples `RemoveFileEvent` sueltos desde `DeletePlayListCommandHandler` (4 llamadas separadas para original/small/medium/large) y luego elimina la entidad en DB. Sin workflow, sin trazabilidad. +**PlayLists:** el borrado disparaba múltiples `RemoveFileEvent` sueltos desde `DeletePlayListCommandHandler` (4 llamadas separadas para original/small/medium/large) y luego eliminaba la entidad en DB. Sin workflow, sin trazabilidad. -**Tracks:** ya existe un workflow (`DeleteTrackEvent` → consumer → RoutingSlip → `DeleteTrackActivity`), pero `DeleteTrackActivity` hace **demasiado en un único paso**: carga la entidad, borra todos los archivos de S3 (pictures + audios), y elimina de DB. Esto rompe el principio de actividades granulares y reutilizables, hace que el slip no sea "seguible" paso a paso, y dificulta los reintentos parciales. +**Tracks:** existía un workflow (`DeleteTrackEvent` → consumer → RoutingSlip → `DeleteTrackActivity`), pero `DeleteTrackActivity` hacía **demasiado en un único paso**: carga la entidad, borra todos los archivos de S3 (pictures + audios), y elimina de DB. Esto rompe el principio de actividades granulares y reutilizables, hace que el slip no sea "seguible" paso a paso, y dificulta los reintentos parciales. La idea es **homogeneizar y corregir ambos**: que los deletes de Track y PlayList sigan un RoutingSlip con actividades pequeñas, ordenadas, repetibles e independientes. @@ -42,30 +42,54 @@ No objetivos (por ahora): ## Diseño propuesto +## Implementación final (nombres reales) + +Este plan se implementó con los siguientes nombres finales (para facilitar traceo/debug): + +### Colas (consumers) +- `delete-track` (`DeleteTrackConsumer`) +- `delete-playList` (`DeletePlayListConsumer`) + +### Execute endpoints (activities) +MassTransit crea las colas de execute como `_execute`. + +- `remove-file-from-bucket` → cola `remove-file-from-bucket_execute` +- `mark-track-as-removing` → cola `mark-track-as-removing_execute` +- `delete-track-from-db` → cola `delete-track-from-db_execute` +- `mark-playList-as-removing` → cola `mark-playList-as-removing_execute` +- `delete-playList-from-db` → cola `delete-playList-from-db_execute` + +### Estado de borrado (DB) +- Se introdujo `LifeCycleStatus` en `Track` y `PlayList` con valores `Active` y `Removing`. +- El handler marca `Removing` antes de publicar el evento (y persiste con `SaveChanges`). + +> Nota: se eliminó el enfoque de “estado Removing” implícito en `ProcessingStatus` para deletes. El tracking de delete queda en `LifeCycleStatus`. + ### Actividades compartidas / reutilizables Ya existe (y se mantiene): - `RemoveFileFromBucketActivity` — borra un único objeto de S3. Se usará N veces en cada slip. -Se añaden actividades de DB compartibles por aggregate: -- `MarkAsRemovingActivity` — cambia estado de la entidad a `Removing` y persiste. (O una por aggregate si la lógica difiere.) +Se añaden actividades de DB por aggregate: +- `MarkTrackAsRemovingActivity` — cambia estado `LifeCycleStatus` a `Removing` y persiste. - `DeleteTrackFromDbActivity` — elimina el Track de EF (cascade elimina relaciones). +- `MarkPlayListAsRemovingActivity` — cambia estado `LifeCycleStatus` a `Removing` y persiste. - `DeletePlayListFromDbActivity` — elimina la PlayList de EF (cascade elimina relaciones). ### Estructura del RoutingSlip para Track ``` DeleteTrackEvent - └─► DeleteTrackConsumer - └─► DeleteTrackRoutingSlipBuilder - ├─ MarkTrackAsRemovingActivity (cambia estado → Removing, persiste) - ├─ RemoveFileFromBucketActivity (original picture) - ├─ RemoveFileFromBucketActivity (small picture) ← solo si IsPicturesProcessed - ├─ RemoveFileFromBucketActivity (medium picture) ← solo si IsPicturesProcessed - ├─ RemoveFileFromBucketActivity (large picture) ← solo si IsPicturesProcessed - ├─ RemoveFileFromBucketActivity (original audio) - ├─ RemoveFileFromBucketActivity (processed audio) ← solo si IsAudioProcessed - └─ DeleteTrackFromDbActivity (Remove() + SaveChanges) + └─► DeleteTrackConsumer (queue: `delete-track`) + └─► DeleteTrackRoutingSlipBuilder.BuildAsync(...) + ├─ MarkTrackAsRemovingActivity (endpoint: `mark-track-as-removing`) + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — original picture + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — small (si IsPicturesProcessed) + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — medium (si IsPicturesProcessed) + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — large (si IsPicturesProcessed) + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — original audio + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — processed audio (si IsAudioProcessed) + └─ DeleteTrackFromDbActivity (endpoint: `delete-track-from-db`) ``` > El builder carga la entidad para construir el plan (keys condicionales) antes de arrancar el slip. Las actividades de S3 son idempotentes (NotFound → log Information + continue). @@ -74,14 +98,14 @@ DeleteTrackEvent ``` DeletePlayListEvent - └─► DeletePlayListConsumer - └─► DeletePlayListRoutingSlipBuilder - ├─ MarkPlayListAsRemovingActivity (cambia estado → Removing, persiste) - ├─ RemoveFileFromBucketActivity (original — uploads/{userId}/...) ← solo si no es preset - ├─ RemoveFileFromBucketActivity (small) ← solo si no es preset - ├─ RemoveFileFromBucketActivity (medium) ← solo si no es preset - ├─ RemoveFileFromBucketActivity (large) ← solo si no es preset - └─ DeletePlayListFromDbActivity (Remove() + SaveChanges, cascade elimina PlayListHasTrack) + └─► DeletePlayListConsumer (queue: `delete-playList`) + └─► DeletePlayListRoutingSlipBuilder.BuildAsync(...) + ├─ MarkPlayListAsRemovingActivity (endpoint: `mark-playList-as-removing`) + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — original (uploads/{userId}/...) ← solo si no es preset + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — small ← solo si no es preset + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — medium ← solo si no es preset + ├─ RemoveFileFromBucketActivity (endpoint: `remove-file-from-bucket`) — large ← solo si no es preset + └─ DeletePlayListFromDbActivity (endpoint: `delete-playList-from-db`) ``` ### Handler de comando (ambos aggregates) @@ -93,14 +117,20 @@ database.PlayLists.Remove(playlist); await database.SaveChangesAsync(); // DESPUÉS +playlist.LifeCycleStatus = LifeCycleStatus.Removing; await eventBus.PublishAsync(new DeletePlayListEvent(command.PlayListId, command.UserId)); -// Nada más. El workflow se encarga de todo. +await database.SaveChangesAsync(); +// El workflow se encarga de borrar ficheros y eliminar en DB. ``` ```csharp // DeleteTrackCommandHandler — ANTES // (ya publicaba DeleteTrackEvent, se mantiene igual) + +// DESPUÉS +track.LifeCycleStatus = LifeCycleStatus.Removing; await eventBus.PublishAsync(new DeleteTrackEvent(command.TrackId, command.UserId)); +await database.SaveChangesAsync(); // La diferencia es que ahora el slip tiene pasos granulares, no una activity monolítica. ``` @@ -120,7 +150,7 @@ await eventBus.PublishAsync(new DeleteTrackEvent(command.TrackId, command.UserId ## Idempotencia / reintentos - RoutingSlip + reintentos de endpoint ya configurados en MassTransit. -- Operaciones S3: idempotentes por diseño. Si el key no existe → log `Information` y `Completed()` (no throw). +- Operaciones S3: idempotentes por diseño. Si el key no existe (`NoSuchKey`/`NotFound`) → log `Information` y `Completed()` (no throw). - DB: si la entidad ya no existe cuando llega `DeleteTrackFromDbActivity` / `DeletePlayListFromDbActivity`, el workflow se considera `Completed` (misma lógica que ahora). - Estado `Removing`: permite identificar entidades en proceso de borrado si el sistema se reinicia a mitad del slip.