Con ExBlog avevo un'idea abbastanza precisa: costruire un blog in Elixir, dove un agente AI potesse aiutarmi a scrivere, modificare, tradurre e pubblicare gli articoli, anche attraverso Telegram o un client MCP.
La parte dell'agente era interessante, ma prima dovevo decidere dove conservare i contenuti.
La soluzione più ovvia era PostgreSQL. Una tabella articles, qualche campo JSON per i metadati, Ecto per le query e via. È un'architettura che conosco e che normalmente funziona benissimo.
Però c'era qualcosa che mi sembrava inutilmente complicato.
I miei articoli erano già documenti Markdown. Volevo poterli modificare con un editor, confrontare le versioni, recuperare una modifica precedente e magari fare una review prima della pubblicazione.
Stavo praticamente descrivendo Git.
Allora perché non utilizzarlo direttamente?
Il problema di Git è quando deve rispondere a Phoenix
Git è ottimo per gestire i contenuti, ma non è pensato per servire una richiesta HTTP ogni volta che qualcuno apre una pagina.
Avrei potuto leggere direttamente i Markdown dal filesystem. Con dieci articoli non sarebbe cambiato molto.
Ma continuare a leggere file, interpretare front matter e ricostruire metadati per ogni richiesta non mi sembrava una grande idea.
E aggiungere una cache sopra Git, mantenendo anche PostgreSQL, avrebbe significato avere tre rappresentazioni degli stessi articoli.
Ho preferito una separazione più netta: Git conserva la versione canonica, ETS contiene la versione pronta per essere letta.
In pratica, all'avvio e durante le sincronizzazioni, ExBlog legge i file Markdown, li interpreta e costruisce un indice ETS con chiavi {lingua, slug}.
Da Phoenix recuperare un articolo significa fare una lookup:
def get(lang, slug) do
case read_table(
&:ets.lookup(&1, {lang, slug})
) do
[{{^lang, ^slug}, article}] -> article
[] -> nil
end
endTutto molto semplice.
Almeno finché non arriva una nuova versione del repository.
Il dettaglio che mi avrebbe rovinato una soluzione altrimenti pulita
Immaginiamo di avere 500 articoli e di ricevere un nuovo commit con qualche modifica.
Devo ricostruire l'indice. Potrei usare :ets.delete_all_objects/1 e reinserire i record aggiornati.
Ma Phoenix non si ferma mentre io ricostruisco ETS.
Se una richiesta arriva nel mezzo, potrebbe trovare soltanto una parte degli articoli. Non sarebbe un problema di dati persi: pochi millisecondi dopo sarebbe tutto di nuovo disponibile. Però dal punto di vista di chi legge, l'articolo potrebbe risultare inesistente.
Quindi mi serviva un modo per pubblicare il nuovo indice tutto insieme.
Ho scelto una soluzione che mi piace per quanto è piccola: costruire una nuova tabella ETS, riempirla completamente e cambiare un solo riferimento.
Il passaggio più importante di ExBlog.Content.Index è questo:
# La nuova tabella contiene già tutti gli articoli old_table = state.table :persistent_term.put(@table_key, table) :ets.delete(old_table)
La tabella attiva è raggiungibile attraverso un riferimento conservato in :persistent_term.
Finché preparo la nuova versione, le richieste continuano a leggere quella precedente. Quando la nuova è pronta, pubblico il riferimento aggiornato.
In sostanza, non modifico l'indice utilizzato dai lettori. Lo sostituisco.
Ma anche qui c'era una piccola trappola.
Un riferimento valido può diventare invalido un attimo dopo
Un processo Phoenix potrebbe recuperare il riferimento alla vecchia tabella un istante prima dello scambio.
Nel frattempo, il processo che gestisce l'indice pubblica la nuova tabella ed elimina quella vecchia.
Il primo processo tenta la lookup su una tabella che ormai non esiste.
Questo può generare un ArgumentError.
La soluzione implementata è abbastanza pragmatica: la lettura recupera il riferimento corrente e, se incontra quella condizione di race, riprova.
defp read_table(fun, attempts \\ 2)
defp read_table(_fun, 0), do: []
defp read_table(fun, attempts) do
table = :persistent_term.get(@table_key, nil)
if is_reference(table) do
fun.(table)
else
[]
end
rescue
ArgumentError ->
read_table(fun, attempts - 1)
endNon è una garanzia assoluta contro ogni race condition. Dopo due tentativi questa implementazione può restituire una lista vuota, che per Phoenix potrebbe sembrare un articolo inesistente. La proprietà utile è più limitata: quando la lettura riesce, consulta una tabella già costruita per intero, non una tabella che sto ancora riempiendo.
Ho voluto verificare la proprietà che mi interessava con un test: 20 processi leggono l'indice mentre viene sostituito un articolo.
In quel test ogni lettore ha osservato l'insieme completo precedente oppure quello nuovo, mai una versione parzialmente caricata. È il risultato del test, non la promessa che ogni lettura futura riuscirà sempre nonostante i retry limitati.
È uno di quei test che raccontano molto più di una lunga descrizione dell'architettura.
E l'agente AI può davvero scrivere su Git?
Sì, ma qui ho voluto mantenere un altro confine.
L'agente può preparare contenuti e proporre modifiche. Le operazioni che cambiano il repository passano però attraverso conferme e codice applicativo.
Il percorso editoriale comprende la scrittura del Markdown, il commit Git, la sincronizzazione con il branch remoto, il push e la ricostruzione dell'indice.
Non utilizzo force-push per risolvere i conflitti. Se un rebase fallisce, preferisco ricevere un errore piuttosto che cancellare silenziosamente modifiche altrui.
C'è anche una lezione importante: Git remoto, filesystem locale ed ETS non partecipano a un'unica transazione ACID. Un errore durante il push va gestito, non mascherato da pubblicazione riuscita.
Non lo farei per qualsiasi applicazione
Ricostruire l'indice significa rileggere i contenuti e crearne una nuova rappresentazione in memoria. Anche aggiornare persistent_term non è un'operazione gratuita, perché può comportare lavoro di garbage collection nella VM.
Se stessi realizzando una piattaforma con migliaia di modifiche al secondo, questa soluzione probabilmente non sarebbe la mia prima scelta.
ExBlog, però, è un sistema editoriale. Ci sono molte letture e relativamente poche scritture.
Lo stato operativo dell'agente continua a vivere separatamente in DETS e quello di Telegram nel proprio storage. Non ho cercato di trasformare Git nella soluzione a qualsiasi problema di persistenza.
Volevo soltanto evitare un database aggiuntivo per conservare file che Git sa già gestire molto bene.
Alla fine la parte che mi ha dato più soddisfazione non è stata eliminare PostgreSQL. È stata trovare un modo abbastanza semplice per far convivere la cronologia di Git con la velocità di lettura di ETS, senza costringere Phoenix ad aspettare il lavoro editoriale.