364 lines
8.4 KiB
Markdown
364 lines
8.4 KiB
Markdown
|
|
# Upload I/O Asynchrone — Documentation
|
||
|
|
|
||
|
|
**Date**: 2025-01-27
|
||
|
|
**Status**: ✅ **IMPLEMENTED** - MOD-P2-008
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Vue d'ensemble
|
||
|
|
|
||
|
|
L'upload de fichiers audio utilise maintenant une **sémantique asynchrone** avec réponse `202 Accepted`. La copie fichier (`io.Copy`) se fait en arrière-plan dans une goroutine suivie, permettant au handler HTTP de répondre immédiatement.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Sémantique HTTP
|
||
|
|
|
||
|
|
### Endpoint: `POST /api/v1/tracks`
|
||
|
|
|
||
|
|
**Réponse**: `202 Accepted`
|
||
|
|
|
||
|
|
**Headers**:
|
||
|
|
```
|
||
|
|
Location: /api/v1/tracks/{track_id}/status
|
||
|
|
```
|
||
|
|
|
||
|
|
**Body**:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"success": true,
|
||
|
|
"data": {
|
||
|
|
"track_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"status": "uploading",
|
||
|
|
"status_url": "/api/v1/tracks/550e8400-e29b-41d4-a716-446655440000/status",
|
||
|
|
"message": "Upload initiated, file is being saved in background"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Flux d'Exécution
|
||
|
|
|
||
|
|
### 1. Handler (`UploadTrack`)
|
||
|
|
|
||
|
|
1. Validation du fichier (ClamAV, format, quota)
|
||
|
|
2. Création du Track en DB avec `Status=Uploading` **immédiatement**
|
||
|
|
3. Lancement de la copie fichier en **goroutine** (`copyFileAsync`)
|
||
|
|
4. Réponse **202 Accepted** avec `track_id`
|
||
|
|
|
||
|
|
### 2. Goroutine Asynchrone (`copyFileAsync`)
|
||
|
|
|
||
|
|
1. Création d'un contexte avec timeout (5 minutes)
|
||
|
|
2. Ouverture du fichier source (`fileHeader.Open()`)
|
||
|
|
3. Création du fichier destination (`os.Create`)
|
||
|
|
4. Copie avec `io.Copy`
|
||
|
|
5. Mise à jour du Status:
|
||
|
|
- `Processing` si succès
|
||
|
|
- `Failed` si erreur
|
||
|
|
6. Nettoyage automatique en cas d'échec (`os.Remove`)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Suivi de Progression
|
||
|
|
|
||
|
|
### Endpoint: `GET /api/v1/tracks/{id}/status`
|
||
|
|
|
||
|
|
**Réponse**: `200 OK`
|
||
|
|
|
||
|
|
**Body**:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"success": true,
|
||
|
|
"data": {
|
||
|
|
"track_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"status": "processing",
|
||
|
|
"progress": 100,
|
||
|
|
"message": "File uploaded, processing..."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Status possibles**:
|
||
|
|
- `uploading`: Fichier en cours de copie
|
||
|
|
- `processing`: Fichier copié, traitement en cours
|
||
|
|
- `completed`: Track prêt
|
||
|
|
- `failed`: Échec (upload ou traitement)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Gestion d'Erreurs
|
||
|
|
|
||
|
|
### Erreurs Synchrones (avant goroutine)
|
||
|
|
|
||
|
|
- **Validation échouée**: `400 Bad Request`
|
||
|
|
- **Quota dépassé**: `403 Forbidden`
|
||
|
|
- **ClamAV unavailable**: `503 Service Unavailable`
|
||
|
|
- **Virus détecté**: `422 Unprocessable Entity`
|
||
|
|
|
||
|
|
### Erreurs Asynchrones (dans goroutine)
|
||
|
|
|
||
|
|
- **Erreur de copie**: Status → `Failed`, fichier nettoyé
|
||
|
|
- **Timeout (5 min)**: Status → `Failed`, fichier nettoyé
|
||
|
|
- **Contexte annulé**: Status → `Failed`, fichier nettoyé
|
||
|
|
|
||
|
|
**Nettoyage automatique**: Le fichier est supprimé (`os.Remove`) en cas d'échec.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Traçabilité
|
||
|
|
|
||
|
|
### Logs
|
||
|
|
|
||
|
|
Tous les logs incluent:
|
||
|
|
- `track_id`: UUID du track
|
||
|
|
- `user_id`: UUID de l'utilisateur
|
||
|
|
- `request_id`: ID de requête (si disponible via context)
|
||
|
|
|
||
|
|
**Exemples**:
|
||
|
|
```
|
||
|
|
INFO Track upload initiated (async) track_id=... user_id=... filename=...
|
||
|
|
INFO Track status updated track_id=... status=processing message=...
|
||
|
|
INFO Track file copied successfully (async) track_id=... bytes_written=...
|
||
|
|
```
|
||
|
|
|
||
|
|
### Request ID
|
||
|
|
|
||
|
|
Le `request_id` est propagé via le contexte:
|
||
|
|
```go
|
||
|
|
ctx := c.Request.Context() // Contient request_id du middleware
|
||
|
|
track, err := service.UploadTrack(ctx, userID, fileHeader)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Exemples cURL
|
||
|
|
|
||
|
|
### 1. Upload d'un fichier
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -X POST http://localhost:8080/api/v1/tracks \
|
||
|
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||
|
|
-F "file=@audio.mp3" \
|
||
|
|
-v
|
||
|
|
```
|
||
|
|
|
||
|
|
**Réponse**:
|
||
|
|
```
|
||
|
|
HTTP/1.1 202 Accepted
|
||
|
|
Location: /api/v1/tracks/550e8400-e29b-41d4-a716-446655440000/status
|
||
|
|
|
||
|
|
{
|
||
|
|
"success": true,
|
||
|
|
"data": {
|
||
|
|
"track_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"status": "uploading",
|
||
|
|
"status_url": "/api/v1/tracks/550e8400-e29b-41d4-a716-446655440000/status",
|
||
|
|
"message": "Upload initiated, file is being saved in background"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Vérifier le statut
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -X GET http://localhost:8080/api/v1/tracks/550e8400-e29b-41d4-a716-446655440000/status \
|
||
|
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||
|
|
```
|
||
|
|
|
||
|
|
**Réponse** (pendant upload):
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"success": true,
|
||
|
|
"data": {
|
||
|
|
"track_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"status": "uploading",
|
||
|
|
"progress": 0,
|
||
|
|
"message": "Upload started"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Réponse** (après copie):
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"success": true,
|
||
|
|
"data": {
|
||
|
|
"track_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"status": "processing",
|
||
|
|
"progress": 100,
|
||
|
|
"message": "File uploaded, processing..."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Réponse** (échec):
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"success": true,
|
||
|
|
"data": {
|
||
|
|
"track_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"status": "failed",
|
||
|
|
"progress": 0,
|
||
|
|
"message": "Failed to save file: ..."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Polling Recommandé
|
||
|
|
|
||
|
|
### Stratégie Simple
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
async function uploadAndWait(trackId) {
|
||
|
|
const maxAttempts = 60; // 5 minutes max (5s * 60)
|
||
|
|
const interval = 5000; // 5 secondes
|
||
|
|
|
||
|
|
for (let i = 0; i < maxAttempts; i++) {
|
||
|
|
const response = await fetch(`/api/v1/tracks/${trackId}/status`);
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (data.data.status === 'completed') {
|
||
|
|
return data.data;
|
||
|
|
}
|
||
|
|
if (data.data.status === 'failed') {
|
||
|
|
throw new Error(data.data.message);
|
||
|
|
}
|
||
|
|
|
||
|
|
await sleep(interval);
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new Error('Upload timeout');
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Stratégie avec Exponential Backoff
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
async function uploadAndWaitWithBackoff(trackId) {
|
||
|
|
let interval = 1000; // 1 seconde initial
|
||
|
|
const maxInterval = 30000; // 30 secondes max
|
||
|
|
const maxAttempts = 120; // ~10 minutes max
|
||
|
|
|
||
|
|
for (let i = 0; i < maxAttempts; i++) {
|
||
|
|
const response = await fetch(`/api/v1/tracks/${trackId}/status`);
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (data.data.status === 'completed') {
|
||
|
|
return data.data;
|
||
|
|
}
|
||
|
|
if (data.data.status === 'failed') {
|
||
|
|
throw new Error(data.data.message);
|
||
|
|
}
|
||
|
|
|
||
|
|
await sleep(interval);
|
||
|
|
interval = Math.min(interval * 1.5, maxInterval); // Exponential backoff
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new Error('Upload timeout');
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Configuration
|
||
|
|
|
||
|
|
### Timeout de Copie
|
||
|
|
|
||
|
|
**Valeur par défaut**: 5 minutes
|
||
|
|
|
||
|
|
**Modification**: `internal/core/track/service.go`
|
||
|
|
```go
|
||
|
|
copyCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Répertoire d'Upload
|
||
|
|
|
||
|
|
**Variable d'environnement**: `UPLOAD_DIR` (optionnel)
|
||
|
|
|
||
|
|
**Valeur par défaut**: `uploads/tracks`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Tests
|
||
|
|
|
||
|
|
### Tests Unitaires
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/core/track -v -run TestUploadTrack_Async
|
||
|
|
```
|
||
|
|
|
||
|
|
**Tests inclus**:
|
||
|
|
- `TestUploadTrack_Async_Success`: Upload réussi, vérification Status
|
||
|
|
- `TestUploadTrack_Async_Interruption`: Gestion interruption (contexte)
|
||
|
|
- `TestUploadTrack_Async_ErrorHandling`: Gestion erreurs
|
||
|
|
- `TestCopyFileAsync_ContextCancellation`: Annulation directe
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Limitations et Notes
|
||
|
|
|
||
|
|
### Limitations Actuelles
|
||
|
|
|
||
|
|
1. **Pas de progression détaillée**: Le `progress` dans `GetUploadStatus` n'est pas mis à jour pendant la copie (reste à 0 jusqu'à 100)
|
||
|
|
2. **Timeout fixe**: 5 minutes (non configurable via env)
|
||
|
|
3. **Pas de retry automatique**: Si la copie échoue, le Track reste en `Failed`
|
||
|
|
|
||
|
|
### Améliorations Futures (Optionnel)
|
||
|
|
|
||
|
|
1. **Progression détaillée**: Utiliser `io.TeeReader` pour suivre les bytes copiés
|
||
|
|
2. **Retry automatique**: Relancer la copie en cas d'erreur réseau
|
||
|
|
3. **Webhooks**: Notifier le client quand l'upload est terminé
|
||
|
|
4. **Chunked upload**: Pour très gros fichiers (>100MB)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Cohérence avec l'Architecture
|
||
|
|
|
||
|
|
### Avantages
|
||
|
|
|
||
|
|
- ✅ **Cohérent** avec `GetUploadStatus` existant
|
||
|
|
- ✅ **Cohérent** avec `Track.Status` (Uploading, Processing, Completed, Failed)
|
||
|
|
- ✅ **Traçabilité complète** (logs + request_id)
|
||
|
|
- ✅ **Nettoyage automatique** en cas d'échec
|
||
|
|
- ✅ **Support cancellation** (context)
|
||
|
|
|
||
|
|
### Intégration
|
||
|
|
|
||
|
|
- ✅ Utilise le système de Status existant
|
||
|
|
- ✅ Compatible avec le traitement asynchrone (streaming, metadata)
|
||
|
|
- ✅ Pas de changement breaking pour les clients (juste nouveau status code)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Dépannage
|
||
|
|
|
||
|
|
### Upload reste en "uploading"
|
||
|
|
|
||
|
|
**Cause**: Goroutine bloquée ou timeout non atteint
|
||
|
|
**Solution**: Vérifier les logs, attendre 5 minutes max
|
||
|
|
|
||
|
|
### Fichier créé mais Status=Failed
|
||
|
|
|
||
|
|
**Cause**: Erreur après copie (validation, DB, etc.)
|
||
|
|
**Solution**: Vérifier les logs pour le message d'erreur
|
||
|
|
|
||
|
|
### Status=Failed immédiatement
|
||
|
|
|
||
|
|
**Cause**: Erreur lors de l'ouverture du fichier source
|
||
|
|
**Solution**: Vérifier que le fichier est valide et accessible
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Références
|
||
|
|
|
||
|
|
- [HTTP 202 Accepted](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/202)
|
||
|
|
- [Async Processing Pattern](https://restfulapi.net/asynchronous-operations-in-rest/)
|
||
|
|
- [Context Package](https://pkg.go.dev/context)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**Dernière mise à jour**: 2025-01-27
|
||
|
|
**Maintenu par**: Veza Backend Team
|