02Prestazioni5 min

Le liste dell'admin sono la prima cosa che cede

Il pannello interno si scrive senza limiti perché all'inizio ci sono quattro righe. Ed è proprio il posto dove salta tutto quando il prodotto cresce.

Di Salva Castells

Immagine di copertina: Marijovanni, CC BY-SA 4.0.
In questo post

Un pannello di amministrazione inizia sempre allo stesso modo: una tabella, quattro righe di prova e un select senza where. Funziona. E siccome funziona, nessuno lo riguarda più. Nel frattempo la tabella degli utenti passa da quattro righe a quarantamila, e la pagina interna che nessuno misura diventa la più lenta del prodotto.

Ho dovuto sistemare questa cosa in quattro progetti diversi, e sempre per gli stessi tre motivi.

L'admin si scrive senza cura perché non lo vede nessuno

La pagina pubblica ha la paginazione dal primo giorno. Qualcuno la rivede, qualcuno la misura, c'è una metrica di Core Web Vitals che urla se ci mette troppo. L'admin non ha niente di tutto questo. Lo usano due persone, e quelle due persone danno per scontato che vada lento perché "è l'admin".

In un'API di trascrizione che mantengo, le server action getAdminUsers e getAdminFeedback ricevevano limit come parametro e lo passavano direttamente alla query. Senza tetto. Nessuno l'aveva toccato da mesi perché dalla UI arrivava sempre un 10. Il problema non è la UI: è che una server action è un endpoint. Chiunque con una sessione da admin, o un qualsiasi script, poteva chiedere limit=1000000 e materializzare l'intera tabella nella memoria del worker.

La correzione è stata di tre righe, in un giro di sicurezza:

export const ADMIN_LIST_MAX_LIMIT = 1000;

const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);

Il Math.max(limit, 1) non è paranoia gratuita: anche un limit negativo o zero fa cose strane a seconda del driver. L'importante è dove vive il tetto. Non nel componente, non nell'hook: nel livello che riceve la richiesta. Tutto quello che sta sopra è un suggerimento.

Il count che sembra gratis e non lo è

La paginazione classica ha bisogno di sapere quante pagine ci sono, e per questo serve un SELECT COUNT(*). Lo scrivi senza pensarci perché in testa un count è economico. Non lo è: in Postgres un count esatto percorre tutta la tabella o tutto l'indice, e non c'è modo di evitarlo.

In un gestore documentale interno l'admin degli utenti faceva tre cose in parallelo a ogni caricamento di pagina: prendere la pagina dei profili, prendere tutte le aziende per il selettore e contare il totale dei profili. Con un pageSize di 5. Cioè si percorreva l'intera tabella per disegnare "pagina 3 di 47".

Ho tolto il count e cambiato il contratto della paginazione:

setHasMore(profilesData.length === pageSize);

Si perde il "di 47". Si guadagna che la query sia sempre proporzionale alla dimensione della pagina. Il bottone "avanti" ora dipende da hasMore invece che da currentPage < totalPages, e l'intervallo mostrato si calcola con quello che hai già in mano, senza chiedere altro:

const endIndex = startIndex + companies.length - 1;

Nessuno si è lamentato di non vedere il totale. È un dato che in un pannello interno non decide assolutamente niente. Se ti serve davvero, reltuples di pg_class ti dà una stima a costo zero; per "ci sono circa 40k utenti" avanza.

Portarsi i metadati invece dell'oggetto intero

Il terzo schema è più sottile. Una lista di file si carica bene finché i file non hanno contenuto. In uno strumento di note collaborative la barra laterale chiamava getProjectTextFiles, che faceva un select senza proiezione: cioè si portava anche la colonna content di ogni file del progetto. Per disegnare un elenco di nomi.

La soluzione è noiosa e funziona: una seconda funzione che seleziona esplicitamente le colonne che servono alla lista.

return await db
  .select({
    id: textFiles.id,
    projectId: textFiles.projectId,
    name: textFiles.name,
    isTemp: textFiles.isTemp,
    deletedAt: textFiles.deletedAt,
    createdAt: textFiles.createdAt,
    updatedAt: textFiles.updatedAt,
  })
  .from(textFiles)
  .where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));

Il contenuto si chiede quando selezioni un file, non prima. E lì salta fuori il dettaglio che si vede solo quando l'hai fatto: se l'utente clicca in fretta su due file, la risposta lenta del primo può sovrascrivere il secondo. Bisogna verificare, prima di scrivere nello store, che il file sia ancora quello selezionato:

if (fullFile && useAppStore.getState().selectedFileId === fileId) {
  useAppStore.getState().setSelectedFile(fullFile);
}

Senza quel controllo scambi un problema di prestazioni con un bug di concorrenza, che è peggio perché non è riproducibile a comando.

Il bug che appare solo nel pannello di admin

Uno dei migliori esempi del perché l'admin merita attenzione me l'ha dato una dashboard di analytics che avevo messo in cache con unstable_cache. Funzionava in locale e alla prima visita in produzione. Poi cadeva con Invalid time value.

unstable_cache serializza il suo payload in JSON. Un Date scritto nell'esecuzione a freddo torna come string in tutte le letture dalla cache. E Intl.DateTimeFormat.format non accetta una stringa. Il tipo mentiva: lastIngestedAt: Date | null era in realtà Date | string | null. TypeScript non poteva vederlo perché il confine di serializzazione non è tipizzato.

L'ho sistemato normalizzando le due forme e trattando l'impronunciabile come assente, non come eccezione:

const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);

Un dato di data che non si può disegnare va mostrato come "nessun dato", non buttare giù l'intera pagina. In una dashboard, una metrica rotta non può portarsi dietro le altre dodici.

Cosa applico oggi a qualunque lista interna

  • Tetto nel livello server. Un limit che arriva da fuori si limita dove entra, con una costante che ha un nome. La UI non è una validazione.
  • Niente count di default. Paginazione per hasMore. Se qualcuno chiede il totale, gli si dà una stima, e solo se lo chiede.
  • Proiezione esplicita. Una lista seleziona colonne, mai *. Il blob si chiede quando apri il dettaglio.
  • Indice sulla colonna per cui filtri. In un'app di timbrature c'erano indici composti (user_id, date), ma le query che filtravano solo per user_id non li sfruttavano bene; è servito aggiungere l'indice semplice.

Il pannello di admin è il posto dove il team guarda quando qualcosa va male in produzione. Che sia proprio la parte del sistema che non regge il volume della produzione è un'ironia costosa.