Un consumidor pide un CSV de todas las peticiones que hizo desde una fecha dada. La versión directa parece inofensiva: cargar las filas, enviar un archivo. Funciona en staging y para tu consumidor más pequeño. Entonces la llama uno con dos millones de filas, la petición se queda sin memoria o sin tiempo, y lo único que ve es una conexión que se cuelga.
Saca el trabajo de la petición. Encólalo, recorre la tabla en trozos para que la memoria se mantenga plana, guarda el archivo y dile al consumidor dónde está:
// app/Jobs/ExportApiRequests.php
public function handle(): void
{
$csv = fopen('php://temp', 'w+');
$rows = ApiRequest::query()
->whereBelongsTo($this->consumer)
->where('created_at', '>=', $this->since)
->lazyById(1000);
foreach ($rows as $row) {
fputcsv($csv, $this->columns($row), escape: '');
}
rewind($csv);
$path = "exports/{$this->consumer->id}"
."/{$this->exportId}.csv";
Storage::put($path, $csv);
$this->consumer->notify(new ExportReady($this->exportId));
}
columns() devuelve una línea del archivo: método, ruta, estado y hora. escape: '' desactiva una vieja rareza del escritor de CSV de PHP que estropea las barras invertidas.
lazyById() nunca tiene en memoria más de mil filas, por grande que sea la tabla. php://temp es un flujo que vive en memoria hasta que pasa de un par de megabytes y entonces se muda solo a un archivo temporal, que PHP elimina cuando el flujo se cierra. Storage pone el resultado en el disco para el que esté configurada la aplicación, que en producción es almacenamiento de objetos y no el sistema de archivos del propio servidor, donde un segundo servidor no podría encontrarlo.
$this->exportId es un UUID generado cuando se pidió la exportación, no cuando se ejecutó el job. Es otra vez la lección del Capítulo 10: un job reintentado escribe en la misma ruta y no deja atrás un segundo archivo.
Lo que se le envía al consumidor no es el archivo ni un enlace público. ExportReady le dice que la exportación ha terminado, y un endpoint autenticado entrega la descarga. Las exportaciones son un recurso como los lotes, con un store y un show, y un controlador nuevo, así que empieza diciendo quién puede llamarlo:
// app/Http/Controllers/ExportController.php
#[Middleware('ability:licenses:read')]
class ExportController
{
public function show(
Request $request,
string $export,
): RedirectResponse {
abort_unless(Str::isUuid($export), 404);
$owner = $request->user()->id;
$path = "exports/{$owner}/{$export}.csv";
abort_unless(Storage::exists($path), 404);
return redirect()->away(Storage::temporaryUrl(
$path,
now()->addMinutes(5),
));
}
}
La ruta se construye a partir del ID de quien llama, nunca de un ID de la URL, así que un consumidor no puede nombrar la exportación de otro. Se comprueba que el ID de la exportación es un UUID antes de que se acerque a una ruta de archivo.
store valida un campo, 'since' => ['required', 'date', 'after:-90 days'], genera el UUID, despacha el job y responde 202 con el ID. store, y solo store, lleva throttle:exports, un limitador que le permite a cada consumidor una exportación cada diez minutos:
// app/Providers/AppServiceProvider.php, en boot()
RateLimiter::for('exports', function (Request $request) {
return Limit::perMinutes(10, 1)
->by((string) $request->user()->id);
});
temporaryUrl() da un enlace que funciona durante cinco minutos y después no, y solo se le emite a alguien que acaba de demostrar quién es. (Documentación de Laravel: File Storage › Temporary URLs.)
Las exportaciones también son datos. Dale a la carpeta una regla de retención: una regla de ciclo de vida en el bucket, o un comando programado, que elimine los archivos al cabo de un día. Sin ella, todas las exportaciones que alguien haya pedido alguna vez seguirán ahí dentro de un año.
Una precaución por si alguna vez exportas texto que teclearon personas: una hoja de cálculo trata una celda que empieza por =, +, - o @ como una fórmula. Antepón una comilla simple a esas celdas. Las columnas aquí son métodos, rutas y códigos de estado, así que no se da el caso.
Dos millones de filas detrás de este patrón te cuestan un worker de la cola durante unos minutos. La versión síncrona cuesta una petición que agota el tiempo y un ticket de soporte.
Saber cuándo una función no vale la pena
La mayor parte de lo que hay en este capítulo no debería entregarse en la mayoría de las APIs. No porque los patrones estén mal, sino porque la mayoría de las APIs nunca llegan al punto en que los webhooks ganan al sondeo, en que cincuenta de una vez es una petición real, o en que un índice de búsqueda vale lo que cuesta su desfase. Construir la versión sofisticada de una función que nadie ha pedido es la arquitectura prematura contra la que este libro ha argumentado desde el prefacio, con un disfraz más respetable.
Hazte primero tres preguntas.
- ¿Ha fallado de verdad la versión simple? No «podría fallar a escala algún día». ¿Ha chocado un consumidor real con un muro real?
- ¿Puedes medir el costo de no tenerla? «Los consumidores sondean el endpoint de listado 200 000 veces al día para atrapar cambios que ocurren cuarenta veces al día» es un número. «Estaría bien tener webhooks» no lo es.
- ¿Estás dispuesto a operarla? Un sistema de webhooks significa un registro de entregas que vigilar. Un endpoint de lotes significa una cola que mantener sana. Una copia de datos significa un desfase que gestionar. Si nadie es dueño de eso, la función no está terminada.
Resumen del capítulo 18
Antes de construir:
- La versión simple está en producción y ha fallado de forma medible.
- Puedes nombrar el costo como un número.
- Alguien es dueño de operarla.
Una vez construida:
- Las entregas de webhooks se pueden demostrar desde una tabla, van firmadas y las reintenta la cola, con los 4xx tratados como definitivos.
- Una URL de webhook es una dirección que eligió otro. Compruébala cuando se guarda y cuando se usa, y no sigas redirecciones.
- Un lote tiene su propio controlador, que declara quién puede llamarlo, responde 202 y ofrece un endpoint de estado que comprueba de quién es el lote.
- La búsqueda consulta la fuente de verdad todo el tiempo que puede. Toda copia de datos tiene una fuente que gana y una forma de reconstruirse.
- Las exportaciones se ejecutan en un job, en trozos, hacia almacenamiento compartido, y las entrega un endpoint autenticado mediante un enlace que caduca en minutos.