205 lines
11 KiB
Markdown
205 lines
11 KiB
Markdown
# 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`, `RemoveFileConsumer` y 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 `RemoveFileEvent` sueltos, sin agruparlos en un proceso único.
|
||
- No hay log de RoutingSlip que permita seguir el progreso/estado.
|
||
|
||
### Tracks ← **nuevo problema identificado**
|
||
- `DeleteTrackActivity` es monolítica: carga entidad, borra pictures, borra audios, elimina de DB, todo en un solo `Execute`.
|
||
- No reutiliza `RemoveFileFromBucketActivity` ya 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` → 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 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 (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)
|
||
|
||
```csharp
|
||
// 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.
|
||
```
|
||
|
||
```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.
|
||
```
|
||
|
||
---
|
||
|
||
## 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 `PlayListHasTrack` al 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`) → 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.
|
||
|
||
---
|
||
|
||
## Plan de implementación (paso a paso)
|
||
|
||
### 1. Inventario
|
||
- Confirmar todos los usos de `RemoveFileEvent` / `RemoveFileConsumer` en 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 estado `Removing`, persiste.
|
||
- Crear `DeleteTrackFromDbActivity` (execute-activity): carga Track, llama `Remove()`, persiste.
|
||
- Actualizar `DeleteTrackRoutingSlipBuilder`:
|
||
- Carga el Track para calcular el plan (keys condicionales según `IsPicturesProcessed`, `IsAudioProcessed`).
|
||
- Encadena: `MarkTrackAsRemoving` → N × `RemoveFileFromBucket` → `DeleteTrackFromDb`.
|
||
- Eliminar `DeleteTrackActivity` monolí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)` en `Application/Events`.
|
||
- Crear `DeletePlayListConsumer` en 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, setea `Removing`, persiste.
|
||
- `DeletePlayListFromDbActivity` (execute-activity): carga PlayList, llama `Remove()`, persiste (cascade).
|
||
|
||
### 5. Actualizar `RemoveFileFromBucketActivity` para NotFound
|
||
- Si el SDK lanza excepción por key inexistente → capturar, log `Information("Key {Key} not found, skipping")`, retornar `Completed()`.
|
||
- Si es otro error → log `Error` + throw (reintento normal).
|
||
|
||
### 6. Cambiar `DeletePlayListCommandHandler`
|
||
- Reemplazar `PublishRemoveFileEventsAsync` por `eventBus.PublishAsync(new DeletePlayListEvent(...))`.
|
||
- No publicar más `RemoveFileEvent` para playlist delete.
|
||
|
||
### 7. Ajustar DI / registration MassTransit
|
||
- Registrar: `DeletePlayListConsumer`, `MarkPlayListAsRemovingActivity`, `DeletePlayListFromDbActivity`.
|
||
- Registrar: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`.
|
||
- Desregistrar: `DeleteTrackActivity` (monolítica), y si procede `RemoveFileConsumer`.
|
||
|
||
### 8. Eliminar código muerto
|
||
- `DeleteTrackActivity` monolítica.
|
||
- `RemoveFileEvent` y `RemoveFileConsumer` (si no tienen otros usos).
|
||
- Endpoints y registrations asociados.
|
||
|
||
### 9. Build y verificación
|
||
- `dotnet build -c Release` sin warnings de referencias muertas.
|
||
- Smoke test manual: borrar un Track y una PlayList, verificar logs de cada activity en orden. |