11 KiB
Plan — Unificar Deletes con Workflows (RoutingSlip)
Contexto
Actualmente hay dos problemas de diseño en los deletes:
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: 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.
Objetivos
- Un único camino de borrado por aggregate (Track / PlayList), basado en RoutingSlip con actividades granulares.
- El handler de comando hace lo mínimo: validar ownership/reglas, cambiar estado a
Removing, y publicar el evento de borrado (outbox). - El workflow ejecuta pasos ordenados, reutilizables y reiniciables individualmente.
- Reutilizar
RemoveFileFromBucketActivity(ya existente) para cada archivo a borrar, en lugar de lógica ad-hoc dentro de una activity monolítica. - Eliminar
RemoveFileEvent,RemoveFileConsumery su registration si no tienen otros usos.
No objetivos (por ahora):
- No introducir observabilidad extra (tracing/telemetría) fuera de lo ya existente.
Problemas del enfoque actual
PlayLists
- El handler implementa orquestación (decide qué archivos borrar y cómo) → acoplamiento alto.
- Publica múltiples
RemoveFileEventsueltos, sin agruparlos en un proceso único. - No hay log de RoutingSlip que permita seguir el progreso/estado.
Tracks ← nuevo problema identificado
DeleteTrackActivityes monolítica: carga entidad, borra pictures, borra audios, elimina de DB, todo en un soloExecute.- No reutiliza
RemoveFileFromBucketActivityya existente. - Si falla a mitad, no hay granularidad de reintento (no se sabe qué paso falló).
- El RoutingSlip tiene un único nodo → no es realmente un "slip" con pasos.
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 <endpointName>_execute.
remove-file-from-bucket→ colaremove-file-from-bucket_executemark-track-as-removing→ colamark-track-as-removing_executedelete-track-from-db→ coladelete-track-from-db_executemark-playList-as-removing→ colamark-playList-as-removing_executedelete-playList-from-db→ coladelete-playList-from-db_execute
Estado de borrado (DB)
- Se introdujo
LifeCycleStatusenTrackyPlayListcon valoresActiveyRemoving. - El handler marca
Removingantes de publicar el evento (y persiste conSaveChanges).
Nota: se eliminó el enfoque de “estado Removing” implícito en
ProcessingStatuspara deletes. El tracking de delete queda enLifeCycleStatus.
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 por aggregate:
MarkTrackAsRemovingActivity— cambia estadoLifeCycleStatusaRemovingy persiste.DeleteTrackFromDbActivity— elimina el Track de EF (cascade elimina relaciones).MarkPlayListAsRemovingActivity— cambia estadoLifeCycleStatusaRemovingy persiste.DeletePlayListFromDbActivity— elimina la PlayList de EF (cascade elimina relaciones).
Estructura del RoutingSlip para Track
DeleteTrackEvent
└─► 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).
Estructura del RoutingSlip para PlayList
DeletePlayListEvent
└─► 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)
// DeletePlayListCommandHandler — ANTES
await PublishRemoveFileEventsAsync(...); // 4 eventos sueltos
database.PlayLists.Remove(playlist);
await database.SaveChangesAsync();
// DESPUÉS
playlist.LifeCycleStatus = LifeCycleStatus.Removing;
await eventBus.PublishAsync(new DeletePlayListEvent(command.PlayListId, command.UserId));
await database.SaveChangesAsync();
// El workflow se encarga de borrar ficheros y eliminar en DB.
// 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.
Reglas de negocio a preservar
- Ownership se valida en el handler del comando (igual que ahora), antes de publicar el evento.
- PlayList: solo borrar original/sizes si no son presets.
- PlayList: el original está bajo
uploads/{userId}/...(cambio reciente). - Track: respetar estados de procesamiento — si audio está
Pending/Processing, el handler ya bloquea el delete antes de llegar al workflow. - EF cascade elimina
PlayListHasTrackal borrar la PlayList → no hace falta activity explícita para ello.
Idempotencia / reintentos
- RoutingSlip + reintentos de endpoint ya configurados en MassTransit.
- Operaciones S3: idempotentes por diseño. Si el key no existe (
NoSuchKey/NotFound) → logInformationyCompleted()(no throw). - DB: si la entidad ya no existe cuando llega
DeleteTrackFromDbActivity/DeletePlayListFromDbActivity, el workflow se consideraCompleted(misma lógica que ahora). - Estado
Removing: permite identificar entidades en proceso de borrado si el sistema se reinicia a mitad del slip.
Plan de implementación (paso a paso)
1. Inventario
- Confirmar todos los usos de
RemoveFileEvent/RemoveFileConsumeren la solución. - Si no tienen otros usos fuera de playlist delete → marcarlos para eliminación en el paso 7.
2. Refactorizar Track delete ← nuevo
- Crear
MarkTrackAsRemovingActivity(execute-activity): carga Track, setea estadoRemoving, persiste. - Crear
DeleteTrackFromDbActivity(execute-activity): carga Track, llamaRemove(), persiste. - Actualizar
DeleteTrackRoutingSlipBuilder:- Carga el Track para calcular el plan (keys condicionales según
IsPicturesProcessed,IsAudioProcessed). - Encadena:
MarkTrackAsRemoving→ N ×RemoveFileFromBucket→DeleteTrackFromDb.
- Carga el Track para calcular el plan (keys condicionales según
- Eliminar
DeleteTrackActivitymonolítica (ya no se usa). - Ajustar registration DI de MassTransit (quitar activity vieja, registrar las nuevas).
3. Crear PlayList delete: evento + consumer + builder
- Añadir
DeletePlayListEvent(PlayListId, UserId)enApplication/Events. - Crear
DeletePlayListConsumeren Infrastructure: recibe el evento y ejecuta el builder. - Crear
DeletePlayListRoutingSlipBuilder:- Carga la PlayList para calcular el plan (keys condicionales por presets, ruta de original).
- Encadena:
MarkPlayListAsRemoving→ N ×RemoveFileFromBucket→DeletePlayListFromDb.
4. Crear actividades de PlayList
MarkPlayListAsRemovingActivity(execute-activity): carga PlayList, seteaRemoving, persiste.DeletePlayListFromDbActivity(execute-activity): carga PlayList, llamaRemove(), persiste (cascade).
5. Actualizar RemoveFileFromBucketActivity para NotFound
- Si el SDK lanza excepción por key inexistente → capturar, log
Information("Key {Key} not found, skipping"), retornarCompleted(). - Si es otro error → log
Error+ throw (reintento normal).
6. Cambiar DeletePlayListCommandHandler
- Reemplazar
PublishRemoveFileEventsAsyncporeventBus.PublishAsync(new DeletePlayListEvent(...)). - No publicar más
RemoveFileEventpara playlist delete.
7. Ajustar DI / registration MassTransit
- Registrar:
DeletePlayListConsumer,MarkPlayListAsRemovingActivity,DeletePlayListFromDbActivity. - Registrar:
MarkTrackAsRemovingActivity,DeleteTrackFromDbActivity. - Desregistrar:
DeleteTrackActivity(monolítica), y si procedeRemoveFileConsumer.
8. Eliminar código muerto
DeleteTrackActivitymonolítica.RemoveFileEventyRemoveFileConsumer(si no tienen otros usos).- Endpoints y registrations asociados.
9. Build y verificación
dotnet build -c Releasesin warnings de referencias muertas.- Smoke test manual: borrar un Track y una PlayList, verificar logs de cada activity en orden.