Ir al contenido principal
Laravel, shipping fast.
Capitulo 13 · Funcionalidades avanzadas

Busqueda consistente con la fuente

Julian Beaujardin

Antes de construir un endpoint de búsqueda, pregunta dónde viven realmente los datos. Esa respuesta decide todo el diseño.

Domain es un modelo real de Eloquent: la fila de la tabla es la fuente de verdad. Buscarla es una consulta, nada más:

// app/Http/Controllers/DomainController.php
public function search(SearchDomainsRequest $request, string $id): CollectionResponse
{
    $site = Site::findOrFail($id);

    $domains = Domain::query()
        ->where('site_id', $site->id)
        ->whereLike('hostname', '%'.$request->validated('q').'%')
        ->when(
            $request->validated('status'),
            fn ($query, $status) => $query->where('status', $status),
        )
        ->orderBy('hostname')
        ->limit(50)
        ->get();

    return new CollectionResponse(
        data: DomainResource::collection($domains),
    );
}

Fíjate en el where('site_id', $site->id) antes del whereLike. Quita esa línea y el endpoint sigue funcionando en cada prueba que escribas contra tu propio sitio, y sigue filtrando los nombres de host de todos los demás inquilinos el día que se registre un segundo cliente. El ámbito no es una optimización: es la diferencia entre una funcionalidad de búsqueda y una brecha de datos con interfaz de consulta. Y como no hay una segunda copia de estos datos en ninguna parte, no hay nada que se quede rancio. Ese es todo el argumento para mantener la búsqueda tan aburrida como puedas: una consulta contra la fuente de verdad es consistente por construcción.

License no tiene ese lujo. LicenseDTO no se hidrata de una tabla: se construye fresco desde una llamada a la API de Statamic cada vez. No hay tabla contra la que ejecutar un whereLike. Aquí es donde los equipos recurren a un índice de búsqueda, y donde aparece el coste real: ya no consultas la fuente de verdad, consultas una copia, y una copia sólo es correcta el instante posterior a sincronizarse.

Si construyes esa copia, constrúyela sobre el mismo evento que ya tienes, escribe una fila en una tabla local de proyección y trata esa tabla como desechable: existe para ser buscada, no para ser tratada como sistema de registro. Cuando Statamic y tu proyección discrepen, gana Statamic, y necesitas una forma de reconstruir la proyección desde cero cuando se separen. Un índice de búsqueda sin camino de reconstrucción no es una funcionalidad: es un pasivo con interfaz de consulta.

Exportaciones largas

Un socio pide un CSV de todos los dominios de uno de sus sitios. La versión directa parece inofensiva, y funciona bien en preproducción con cuarenta dominios de prueba y para tu cliente más pequeño. Luego llega un socio con decenas de miles: Domain::all() intenta sostener todas las filas en memoria antes de que la respuesta empiece siquiera, la petición agota la memoria o el tiempo, y el socio sólo ve una conexión colgada.

Mueve el trabajo fuera de la petición por completo. Encólalo, recorre la tabla por lotes para que la memoria se mantenga plana sea cual sea el número de filas, y avisa a quien llama cuando esté listo:

// app/Jobs/DomainExportJob.php
final class DomainExportJob implements ShouldQueue
{
    use Queueable;

    public int $timeout = 600;

    public function __construct(
        public string $siteId,
        public ?string $webhook,
    ) {}

    public function handle(): void
    {
        $path = storage_path('app/exports/domains-'.$this->siteId.'-'.now()->timestamp.'.csv');
        $handle = fopen($path, 'w');
        fputcsv($handle, ['hostname', 'status', 'activated_at']);

        Domain::query()
            ->where('site_id', $this->siteId)
            ->orderBy('id')
            ->chunkById(500, function ($domains) use ($handle) {
                foreach ($domains as $domain) {
                    fputcsv($handle, [
                        $domain->hostname,
                        $domain->status->value,
                        $domain->activated_at?->toIso8601String(),
                    ]);
                }
            });

        fclose($handle);

        if ($this->webhook) {
            Http::asJson()->post($this->webhook, [
                'event' => 'domains.export.completed',
                'data' => ['path' => basename($path)],
            ]);
        }
    }
}

chunkById(500, ...) nunca carga más de quinientas filas a la vez, y ordena por clave primaria para seguir siendo seguro mientras otras peticiones escriben en la misma tabla. Tarde la exportación cuatro segundos o cuatro minutos, la memoria se mantiene plana. El webhook al final cierra el bucle: el mismo mecanismo de entrega de antes le dice a quien llama que el fichero está listo, en lugar de obligarle a consultar un endpoint de estado cada pocos segundos.

Diez mil dominios con este patrón te cuestan un worker durante unos minutos. La versión síncrona te cuesta una petición agotada y un ticket de soporte. El mismo fichero de salida, un modo de fallo completamente distinto.