feat(infra): track saga failure handling + cleanup (phase 2)

On definitive failure of the picture or audio slip, the saga now reaches a
Failed state and recovers end-to-end:
- Picture/audio track slips carry TrackId + ProcessKind variables and a
  subscription to a fault translator (TrackProcessingSlipFaultConsumer) that
  turns RoutingSlipFaulted into a typed Track{Picture,Audio}ProcessingFailed
- The saga stores the final object keys from the start event and, on failure,
  transitions to Failed and publishes TrackProcessingFailed
- TrackProcessingFailedConsumer runs a cleanup slip that removes the orphaned
  final picture/audio copies and marks the track LifeCycleStatus=Failed
  (MarkTrackAsFailedActivity) — the track stays as a visible, recoverable record
- Migration for the new saga columns

Verified: Worker boots against RabbitMQ + Postgres in Docker; the new
endpoints (fault translator, failed consumer, mark-track-as-failed) configure
and the bus starts clean.

Build: 0 warnings, 0 errors.
This commit is contained in:
2026-07-05 14:49:33 +02:00
parent 97855d9094
commit 6e5755039f
15 changed files with 840 additions and 2 deletions
@@ -0,0 +1,42 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.Tracks;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class TrackProcessingFailedConsumer(IBus bus) : IConsumer<TrackProcessingFailed>
{
public async Task Consume(ConsumeContext<TrackProcessingFailed> context)
{
var message = context.Message;
var builder = new RoutingSlipBuilder(NewId.NextGuid());
builder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey))
builder.AddActivity(
ActivityNames.RemoveTrackOriginalPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey));
if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.AudioKey))
builder.AddActivity(
ActivityNames.RemoveTrackOriginalAudio,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(message.Bucket, message.AudioKey));
builder.AddActivity(
ActivityNames.MarkTrackAsFailed,
EndpointHelper.BuildExecuteActivityUri(MarkTrackAsFailedActivity.ExecuteEndpointName),
new MarkTrackAsFailedArguments(message.TrackId));
await bus.Execute(builder.Build());
}
}
}
@@ -0,0 +1,33 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class TrackProcessingSlipFaultConsumer : IConsumer<RoutingSlipFaulted>
{
public const string QueueName = "track-processing-slip-fault";
public async Task Consume(ConsumeContext<RoutingSlipFaulted> context)
{
var variables = context.Message.Variables;
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.ProcessKind, out var kindObject))
return;
if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TrackId, out var trackObject)
|| !Guid.TryParse(trackObject?.ToString(), out var trackId))
return;
switch (kindObject?.ToString())
{
case RoutingSlipVariableNames.ProcessKinds.TrackPicture:
await context.Publish(new TrackPictureProcessingFailed(trackId));
break;
case RoutingSlipVariableNames.ProcessKinds.TrackAudio:
await context.Publish(new TrackAudioProcessingFailed(trackId));
break;
}
}
}
}