Primer trabajo real de QueueLab. Recibe un fichero CSV, valida su cabecera y devuelve el recuento de filas y estadísticas simples por columna. Es deliberadamente pequeño: sirve para ejercitar subida, almacenamiento, procesamiento en streaming, resultado descargable y reintentos, no para hacer análisis de datos.
Este documento fija el contrato; lo implementan #28 (almacenamiento), #29 (subida), #30 (procesamiento), #31 (resultado) y #32 (filas erróneas y limpieza).
POST /api/v1/jobs solo acepta los tipos que el worker sabe ejecutar (queuelab.jobs.types):
| Tipo | Estado |
|---|---|
noop |
Implementado (trabajo de prueba). |
csv-import |
Implementado (#30): ver Procesamiento. |
Otros (image-resize, conversión de ficheros, análisis por lotes…) |
No implementados: la API responde 400 con la lista de tipos admitidos. |
Un tipo nuevo se añade a la lista en el mismo cambio que su ejecutor, nunca antes.
| Aspecto | Regla |
|---|---|
| Codificación | UTF-8; se tolera un BOM inicial. Otra codificación o bytes inválidos → fallo permanente. |
| Sintaxis | CSV según RFC 4180: separador ,, campos entre " con "" como comilla escapada y saltos de línea dentro de comillas; fin de línea LF o CRLF. |
| Cabecera | Obligatoria, en la primera línea. Nombres no vacíos, sin duplicados (sin distinguir mayúsculas), de hasta 100 caracteres. Hasta 100 columnas. |
| Filas | Cada fila debe tener tantos campos como la cabecera. Un fichero con solo cabecera es válido (0 filas). Las líneas totalmente vacías al final se ignoran; en mitad solo valen si hay una columna (valor vacío). |
| Tipo declarado | Content-Type: text/csv (o extensión .csv); lo demás se rechaza al subir. |
POST /api/v1/jobs/csv, multipart/form-data con la parte file (ver backend/README.md).
201 con el trabajo QUEUED; 413 si supera el tamaño máximo; 415 si no se declara como CSV; 400 si está vacío, no es UTF-8 o
la primera línea está en blanco. Un rechazo no deja ficheros ni trabajos. En la subida solo se comprueba lo mínimo; la cabecera
completa y las filas se validan al procesar.
| Límite | Valor por defecto | Dónde se aplica |
|---|---|---|
| Tamaño máximo del fichero | 10 MiB (queuelab.csv.max-file-size) |
En la subida, antes de guardar nada: lo que lo supere se rechaza con 413 y no deja ficheros. |
| Columnas | 100 | Al leer la cabecera (fallo permanente si se supera). |
| Tamaño de un campo | 1 MiB | Al leer (fallo permanente con el número de línea): evita que unas comillas sin cerrar agoten la memoria. |
| Memoria del worker | O(columnas), no O(filas) | El fichero se procesa en streaming; nunca se carga entero. |
No hay un límite de filas aparte: lo acota el tamaño del fichero.
Dos cosas, ambas deterministas para un mismo fichero:
resultdel trabajo (texto corto, cabe envarchar(1000)):CSV procesado: 1250 filas, 4 columnas.- Fichero de estadísticas
<jobId>.stats.json, guardado en el almacenamiento y descargable (#31):
{
"rows": 1250,
"columns": [
{"name": "id", "type": "number", "nonEmpty": 1250, "empty": 0, "min": 1, "max": 1250, "sum": 781875, "mean": 625.5},
{"name": "city", "type": "text", "nonEmpty": 1248, "empty": 2, "minLength": 3, "maxLength": 22}
]
}rowscuenta las filas de datos (sin la cabecera).- Una columna es
numbersi todos sus valores no vacíos son numéricos (decimal con.); si no,text. Vacío es la cadena vacía; un espacio no lo es. numberañademin,max,sumymean(media de los no vacíos; sin valores no vacíos, la columna estexty no lleva más estadísticas que los recuentos).textañademinLengthymaxLength(en caracteres) de los no vacíos.
GET /api/v1/jobs/{id}incluyeresult(texto) y, solo si el trabajo estáCOMPLETEDy el fichero existe,resultFileconname,contentType,sizeydownloadUrl(/api/v1/jobs/{id}/result).- Un trabajo pendiente,
RUNNING,RETRYINGoFAILEDno anuncia resultado (resultFile: null) y su descarga responde409; un trabajo inexistente o sin fichero responde404.
| Situación | Resultado |
|---|---|
| Cabecera ausente, vacía, con nombres vacíos o duplicados, o con más de 100 columnas | FAILED con un mensaje claro; sin reintentos (reintentar no cambia el fichero). |
| Fila con distinto número de campos, o comillas sin cerrar | FAILED indicando el número de línea; sin reintentos: el primer intento es el único (attempts = 1) y el trabajo queda FAILED (#30, verificado en #32). |
| Fichero no encontrado en el almacenamiento, o error de E/S | Fallo transitorio: se reintenta con espera exponencial como cualquier trabajo. |
El mensaje de error nunca incluye el contenido de las filas, solo la línea y el motivo.
El CSV subido y el fichero de estadísticas viven en el directorio configurado de FileStorage
(queuelab.storage.directory, ver backend/README.md) bajo
inputs/ y results/; el nombre lo asigna el sistema (<jobId>), nunca el cliente.
El worker ejecuta una limpieza periódica (StorageCleaner) con estas reglas:
| Fichero | Cuándo se borra | Propiedad (por defecto) |
|---|---|---|
Entrada de un trabajo COMPLETED |
1 h después de terminar: ya no hace falta | queuelab.cleanup.completed-input-retention (1h) |
Entrada de un trabajo FAILED |
7 días después de fallar: se conserva para poder reintentarlo a mano | queuelab.cleanup.failed-input-retention (7d) |
Resultado (<jobId>.stats.json) |
30 días después de terminar | queuelab.cleanup.result-retention (30d) |
Temporales .tmp-* de escrituras interrumpidas, y ficheros huérfanos (ningún trabajo apunta a ellos) |
1 h después de su última modificación (margen para no borrar una subida en curso) | queuelab.cleanup.temporary-retention (1h) |
- Nunca se toca nada de un trabajo
QUEUED,RUNNINGoRETRYING: la limpieza solo mira estados terminales. - Tras borrar un fichero se anula su referencia (
jobs.input_ref/jobs.result_ref). Un resultado limpiado deja de anunciarse (resultFile: null) y su descarga responde404; reintentar a mano un fallido cuya entrada ya se limpió termina otra vez enFAILEDcon «El trabajo no tiene fichero de entrada» (sin reintentos inútiles). - La referencia se anula antes de borrar el fichero y solo si el trabajo sigue terminado: un reintento manual concurrente gana y conserva su entrada. Si el borrado falla, la referencia se restaura y se reintenta en la siguiente pasada.