Tra le parti di ExWapp che mi hanno fatto ragionare di più ci sono gli ACK (acknowledgement), cioè le conferme di ricezione a livello di protocollo WhatsApp.
Sembrano dettagli di basso livello. Il client invia un messaggio, riceve una conferma, aggiorna lo stato. Finita lì.
Peccato che in un protocollo reale le cose non siano sempre così ordinate.
Nel repository ho un test di regressione che racconta bene il problema: il server aveva confermato un messaggio, ma il client non riusciva ad associargli correttamente quella conferma. Il messaggio rimaneva :pending, come se nessuno avesse mai risposto.
E a quel punto un'operazione riuscita poteva apparire come un timeout.
Lo stesso contatto, due identità
WhatsApp utilizza diverse forme di indirizzamento. In ExWapp devo gestire, tra le altre cose, sia gli identificativi legati al numero telefonico sia i LID, identificativi alternativi che non coincidono con il numero.
Il problema emerge quando un messaggio viene memorizzato sotto un identificativo e la conferma arriva con l'altro.
Nel test ho proprio questa situazione:
@lid "70000004444@lid" @pn "15550004444@s.whatsapp.net"
Il messaggio è stato salvato usando il LID, ma il server restituisce un ACK con l'indirizzo telefonico.
Se provo a trovare il messaggio usando soltanto l'identificativo telefonico arrivato nell'ACK, non trovo la conversazione che avevo salvato sotto il LID.
La parte fastidiosa è che un'operazione di aggiornamento può anche restituire :ok senza aver modificato nessun messaggio. Quindi il sistema sembra aver elaborato correttamente l'ACK, ma lo stato è rimasto invariato.
Nel test di regressione verifico esattamente questo caso (estratto, con gli helper del test omessi):
store =
store_with_pending(@lid, ["MSG_CROSS_FAMILY"])
{:ok, _state} =
Receipt.handle_ack(
ack(%{
"id" => "MSG_CROSS_FAMILY",
"from" => @pn
}),
self(),
%{store: store}
)
assert %{status: :sent} =
Store.find_message(
store, @lid, "MSG_CROSS_FAMILY"
)Il punto non è soltanto riconoscere un altro formato di ID. È capire che identità del destinatario, identificativo del messaggio e stato di consegna non sono la stessa cosa.
Non posso assumere che il server restituisca esattamente la stessa rappresentazione che avevo usato internamente.
Un errore del genere è particolarmente insidioso in un sistema di messaggistica: l'utente potrebbe tentare un nuovo invio perché il primo sembra fallito, anche quando il server l'aveva già accettato.
Poi c'è il problema opposto: inviare gli ACK
Gli ACK non sono importanti soltanto quando li ricevo. Il client deve anche rispondere a determinati eventi del protocollo.
Qui il problema diventa più architetturale.
ExWapp mantiene una coda per le operazioni in uscita. Deve poter conservare comandi durante una disconnessione e riprendere a inviarli quando il trasporto torna disponibile.
Una semplice FIFO sarebbe la scelta più naturale. Il primo comando inserito è il primo a uscire.
Ma se ho migliaia di messaggi ordinari in attesa, perché una conferma necessaria al protocollo dovrebbe aspettare dietro a tutti loro?
Avevo bisogno di preservare l'ordine delle scritture sul trasporto, senza trattare ogni operazione con la stessa priorità.
E qui mi è tornata utile una funzione di Erlang che probabilmente molti sviluppatori Elixir non hanno mai usato: :queue.in_r/2.
queue = :queue.from_list([
{:message, 1},
{:message, 2},
{:message, 3}
])
queue = :queue.in_r({:ack, 42}, queue)
:queue.to_list(queue)
# [
# {:ack, 42},
# {:message, 1},
# {:message, 2},
# {:message, 3}
# ]Non serve un secondo broker né una coda distribuita. Le operazioni normali entrano in fondo, quelle critiche davanti.
Nell'implementazione reale la coda è anche limitata, con capacità predefinita di 10.000 elementi. Se è piena, l'inserimento prioritario può rimuovere l'elemento in fondo, notificando alla sessione l'operazione scartata.
Questo significa accettare un compromesso che non è molto simpatico: un invio applicativo in attesa può fallire per lasciare spazio al protocollo.
L'alternativa sarebbe consentire alla coda di crescere indefinitamente, oppure lasciare che gli ACK aspettino troppo.
Nessuna delle due mi piaceva.
Una coda limitata non garantisce da sola il backpressure
C'è però una sottigliezza importante per chi usa GenServer.
L'inserimento nella coda interna avviene tramite GenServer.cast. Il suo :ok indica soltanto che la chiamata asincrona è stata effettuata: non garantisce che il processo destinatario esista ancora o abbia elaborato il comando, né che l'elemento sia entrato nella coda o sia stato spedito sul WebSocket.
E se i produttori inviano cast a una velocità superiore a quella con cui il GenServer riesce a elaborarli, può crescere anche la sua mailbox.
Limitare state.queue non equivale automaticamente a limitare tutto il traffico in ingresso.
Per un utilizzo molto intenso bisogna osservare anche message_queue_len, gli errori di coda piena e i tempi delle conferme. Eventualmente il rate limiting deve agire prima dell'accodamento.
È un compromesso che preferisco rendere esplicito piuttosto che nascondere dietro un :ok.
Che cosa significa davvero "messaggio inviato"?
Anche questa definizione ha richiesto attenzione.
Nell'API pubblica di ExWapp, il successo di send_message indica che la stanza cifrata è stata scritta sul trasporto. Non significa che il destinatario l'abbia letta, e nemmeno che il server abbia necessariamente confermato l'accettazione.
Quelle sono transizioni successive, gestite attraverso eventi asincroni.
Alla fine, lavorando su questa parte, mi sono reso conto di quanto sia facile modellare male una cosa apparentemente banale come «invia un messaggio».
Non basta far arrivare i byte al socket.
Bisogna mantenere lo stato crittografico, riconoscere le identità, conservare le informazioni necessarie ai retry, dare priorità ai messaggi di controllo e soprattutto non dichiarare consegnato qualcosa che non è ancora stato confermato.
La parte difficile di un client di messaggistica non è inviare. È sapere con precisione che cosa è successo dopo.