Files
Musify/docs/delete-workflows-plan.md
T
2026-05-14 14:32:19 +02:00

175 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan — Unificar Deletes con Workflows (RoutingSlip)
## Contexto
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.
**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.
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
### 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<TArgs>` — cambia estado de la entidad a `Removing` y persiste. (O una por aggregate si la lógica difiere.)
- `DeleteTrackFromDbActivity` — elimina el Track de EF (cascade elimina relaciones).
- `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)
```
> 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
└─► 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)
```
### Handler de comando (ambos aggregates)
```csharp
// DeletePlayListCommandHandler — ANTES
await PublishRemoveFileEventsAsync(...); // 4 eventos sueltos
database.PlayLists.Remove(playlist);
await database.SaveChangesAsync();
// DESPUÉS
await eventBus.PublishAsync(new DeletePlayListEvent(command.PlayListId, command.UserId));
// Nada más. El workflow se encarga de todo.
```
```csharp
// DeleteTrackCommandHandler — ANTES
// (ya publicaba DeleteTrackEvent, se mantiene igual)
await eventBus.PublishAsync(new DeleteTrackEvent(command.TrackId, command.UserId));
// 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 → 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.