Design e implementazione di un servizio API che gestisce Resources e un albero derivato di Nodes.
Orchestrazione asincrona dei workflow, transactional outbox, generation control, PostgreSQL + ltree
e una pipeline interna multi-fase per la riconciliazione dell'albero.
System DesignAsync WorkflowsPostgreSQL + ltreeTransactional OutboxTree ReconciliationIdempotency
Sezione 1
Il Compito
Il sistema gestisce due entità fondamentali: Resources, modificabili dal client tramite API,
e Nodes, aggiornati esclusivamente dal sistema interno in risposta alle modifiche sulle Resources.
I Nodes sono organizzati in un albero gerarchico, dove ogni nodo possiede:
class — tipo del nodo, definisce la logica di business associata, ovvero, indica al sistema quali regole applicare quando deve processarlo
resources — insieme delle Resources attualmente collegate al nodo
properties — attributi calcolati dall'Engine, tra cui un campo status
children — nodi figli nell'albero
aggregated_status — valore aggregato bottom-up: max(properties.status, max(child.aggregated_status))
Le operazioni sulle Resources (Create, Update A/B/C, Delete) hanno complessità diverse e sono classificate
in Short, Medium e Long a seconda del carico computazionale atteso.
Dopo ogni operazione, un workflow interno ricalcola il mapping Resource→Nodes: se il mapping cambia,
i Nodes vengono aggiornati. Se un Node perde tutte le sue Resources, viene eliminato insieme all'intero
sottoalbero. Le dipendenze tra classi di Nodes possono causare aggiornamenti indiretti trasversali all'albero.
Diagramma 1 — Panoramica del dominio
Diagramma 1 — Flusso Client → API Service → Resources DB → Mapping Engine → Node Tree; a destra un dettaglio della struttura ad albero.
Diagramma 2 — Struttura di un Node e propagazione di aggregated_status
L'aggregated_status di ogni nodo è il massimo tra il proprio properties.status
e l'aggregated_status di tutti i figli. Il valore si propaga bottom-up: prima si calcolano
le foglie, poi i nodi intermedi, infine la radice.
Diagramma 2 — Le frecce verdi mostrano la propagazione bottom-up: ogni nodo eredita il valore massimo dai suoi discendenti.
Sezione 2
Design dell'Infrastruttura
Pattern asincrono e code per complessità
Il client riceve immediatamente una risposta 202 Accepted con un task_id,
mentre il lavoro pesante viene eseguito in background da worker dedicati. Questo design evita timeout HTTP
su operazioni lunghe, consente il crash recovery automatico e rende sicuri i retry del client.
Le operazioni vengono smistate su code separate — q:short, q:medium,
q:long — per garantire che i job leggeri non vengano bloccati da quelli pesanti.
Contratto API e idempotenza
Ogni richiesta di mutazione usa POST /resources/... con un header
Idempotency-Key. Il sistema calcola un hash del payload: se la combinazione
chiave + hash è già nota, il task esistente viene restituito senza crearne uno nuovo.
Il client fa polling su GET /tasks/{id} per leggere lo stato di avanzamento.
Lease e heartbeat sui task
Ogni task in esecuzione tiene aggiornati i campi lease_owner, lease_expires_at,
heartbeat_at e attempt_count. Se un worker crasha, il lease scade e un altro
worker può riprendere il task automaticamente. Il contatore di tentativi permette di limitare i retry
infiniti e di marcare il task come fallito dopo un numero massimo di errori.
Pipeline a stage e generation control
Il workflow interno è decomposto in sette stage persistiti nel database:
Accept → Mutation → Mapping → Topology Sync → Properties → Aggregated Status → Finalizer.
Lo stato di ogni stage è scritto transazionalmente, permettendo il ripristino dal punto di interruzione.
Per evitare sovrascritture fuori ordine, ogni scope (contesto di lavoro) ha una
generation monotona. Prima di operare, ogni stage verifica di lavorare sulla generation
più recente: se una nuova richiesta ha già avanzato la generation, lo stage corrente si ferma con
stato superseded senza sovrascrivere dati più recenti.
Transactional Outbox
Nello stesso commit del database viene scritto sia il dato aggiornato sia un evento nella tabella
outbox_events. Un publisher separato legge periodicamente la outbox e pubblica gli eventi
sul message broker. Questo pattern elimina la finestra di inconsistenza tra aggiornamento DB e
pubblicazione broker: se il sistema crasha dopo il commit, l'evento sarà comunque pubblicato al
prossimo polling.
Diagramma 3 — Flusso asincrono e pipeline a stage
Diagramma 3 — Il client riceve subito 202 Accepted; il worker esegue i 7 stage della pipeline in background, rientrando dal punto di interruzione in caso di crash.
Diagramma 4 — Transactional Outbox
Diagramma 4 — Il Transactional Outbox garantisce che DB e broker siano sempre allineati: dato e evento vengono scritti nello stesso commit atomico.
Diagramma 5 — Schema dati (ER semplificato)
Diagramma 5 — Schema ER semplificato: le frecce tratteggiate rappresentano le foreign key principali.
Sezione 3
Implementazione: Aggiornamento dei Nodes
Il cuore del sistema è l'algoritmo di riconciliazione dell'albero dei Nodes, articolato in sette fasi
sequenziali. L'algoritmo è progettato per essere idempotente, sicuro ai retry e corretto in presenza di
operazioni concorrenti grazie al generation control.
Fase 1 — Eliminazione Nodes orfani
Viene calcolata la differenza tra il vecchio mapping (Resource→Nodes) e il nuovo: i Nodes che non hanno
più alcuna Resource associata sono orfani. L'algoritmo riduce l'insieme agli soli orfani
radice (quelli non già coperti dal sottoalbero di un altro orfano), poi esegue una singola
DELETE FROM nodes WHERE path <@ root.path per ognuno, sfruttando ltree.
Il risultato è l'effective_new_mapping, il mapping ripulito dai riferimenti invalidi.
Fase 2 — Inserimento nuovi Nodes
I nuovi Nodes vengono ordinati parent-before-child per rispettare i vincoli referenziali.
Per ogni Node viene calcolata una business key stabile: hash(class || parent_bk || position_token).
L'inserimento avviene via UPSERT … ON CONFLICT DO UPDATE per garantire l'idempotenza in caso
di retry del worker.
Fase 3 — Sincronizzazione mapping (delta sync)
La join table resource_nodes viene aggiornata per delta: vengono cancellate solo le
associazioni rimosse (old_mapping MINUS new_mapping) e inserite solo quelle nuove
(new_mapping MINUS old_mapping). Non si fa mai un rebuild completo della tabella.
Fase 4 — Impatto diretto
Vengono identificati i Nodes il cui set di Resources è cambiato, più i nuovi Nodes appena creati.
Questi formano il direct impact set, il punto di partenza per il ricalcolo delle properties.
Fase 5 — Impatto indiretto (Node Config)
Il NodeConfig definisce le dipendenze tra classi di Nodes (es. "la classe B dipende dalla
classe A"). A partire dalle classi direttamente impattate, l'algoritmo calcola la chiusura
transitiva del grafo delle dipendenze, aggiungendo al set di impatto tutti i Nodes delle classi
raggiunte. Questo garantisce che le conseguenze indirette di una modifica vengano sempre propagate.
Fase 6 — Aggiornamento properties con Engine
Le classi impattate vengono ordinate tramite topological sort sul grafo delle dipendenze,
garantendo che ogni classe venga processata solo dopo le sue dipendenze. Per ciascuna classe,
il contesto viene caricato in bulk (evitando query N+1), l'Engine viene invocato
come oracolo per calcolare le nuove properties, e i risultati vengono persistiti.
Fase 7 — Ricalcolo aggregated_status bottom-up
Viene costruita la status_area: Nodes con properties cambiate + nuovi Nodes + padri dei
sottoalberi cancellati + tutti i loro antenati fino alla radice. Il ricalcolo avviene dal livello
più profondo verso la radice con una query aggregata:
SELECT parent_id, MAX(aggregated_status) … GROUP BY parent_id.
Se la classe definisce patch_aggregated_status, quel valore sovrascrive il calcolo.
Diagramma 6 — Le 7 fasi dell'algoritmo
Diagramma 6 — Le 7 fasi sequenziali dell'algoritmo di riconciliazione. Ogni fase è persistita nel database per consentire il recovery.
Diagramma 7 — Propagazione delle dipendenze tra classi
Quando la classe B viene impattata direttamente, il sistema percorre il grafo delle dipendenze e
raggiunge transitivamente anche le classi A e D che dipendono da B (direttamente o indirettamente).
Diagramma 7 — La chiusura transitiva del grafo delle dipendenze garantisce che tutte le classi influenzate vengano ricalcolate.
Diagramma 8 — Ricalcolo bottom-up di aggregated_status
Dopo l'aggiornamento delle properties, l'algoritmo costruisce la status_area (tutti i nodi
coinvolti più i loro antenati) e ricalcola l'aggregated_status risalendo dall'albero
dai nodi foglia verso la radice.
Diagramma 8 — Il ricalcolo procede per livelli dal basso verso l'alto: prima le foglie (step 1), poi i nodi intermedi (step 2), infine la radice (step 3).
Sezione 4
Ottimizzazioni
Il design include una serie di ottimizzazioni mirate a ridurre il numero di operazioni DB,
la latenza del workflow e il rischio di anomalie in scenari concorrenti:
ltree + indice GiST — Le operazioni sull'albero (eliminazione di sottoalberi,
query di discendenza) avvengono con un singolo predicato path <@ root su un indice
efficiente, senza richiedere query ricorsive o join multipli.
Riduzione ai root di cancellazione — Prima di cancellare i Nodes orfani,
l'algoritmo riduce l'insieme ai soli antenati radice: se un orfano è già coperto dal sottoalbero
di un altro orfano, viene escluso. Si evitano così cancellazioni ridondanti e potenziali conflitti.
effective_new_mapping — Dopo la cancellazione degli orfani, il mapping viene
"ripulito" (i riferimenti a Nodes eliminati vengono rimossi) prima di proseguire con i passi
successivi. Questo impedisce che stage successivi lavorino su dati già invalidati.
Upsert idempotente con business key stabile — La business key deterministica
per ogni nuovo Node (hash(class||parent_bk||position_token)) garantisce che i retry
del worker producano lo stesso risultato senza creare duplicati o causare errori di conflitto.
Delta sync del mapping — La join table resource_nodes viene
aggiornata per differenza (solo le righe cambiate), non con un rebuild completo. Riduce le
operazioni DB e i lock sulla tabella durante aggiornamenti ad alta frequenza.
Bulk context loading per l'Engine — Il contesto (Resources, Nodes correlati,
configurazioni) viene caricato in un'unica query per classe prima di invocare l'Engine,
eliminando il pattern N+1 che si verificherebbe con caricamento Node-per-Node.
Query aggregata per aggregated_status — Il ricalcolo usa una sola query per
livello (SELECT parent_id, MAX(aggregated_status) GROUP BY parent_id) invece di
aggiornamenti individuali nodo-per-nodo. Riduce drasticamente il numero di round-trip al DB
su alberi profondi.
Topological sort sulle classi — Ordinare le classi prima del ricalcolo delle
properties garantisce che ogni classe utilizzi i valori già aggiornati delle sue dipendenze,
evitando iterazioni multiple o propagazioni incorrette.
Sezione 5
Conclusione
La soluzione non è un semplice flusso "happy path": ogni scelta architetturale è motivata da
un preciso scenario di fallimento reale. Il pattern asincrono con lease e heartbeat gestisce i
crash dei worker senza perdita di lavoro. Il generation control impedisce sovrascritture fuori
ordine quando più richieste concorrenti avanzano lo stesso scope. Il Transactional Outbox elimina
la finestra di inconsistenza tra aggiornamento del database e pubblicazione degli eventi sul broker.
L'idempotenza end-to-end — dall'Idempotency-Key all'UPSERT con business key stabile —
rende sicuri i retry del client a qualsiasi livello dello stack.
Sul fronte della correttezza dell'albero, la riduzione agli orfani radice e l'effective_new_mapping
prevengono riferimenti invalidi nelle fasi successive. La chiusura transitiva del grafo delle dipendenze
garantisce che nessuna conseguenza indiretta venga persa, anche su grafi di classi profondi o ciclici.
Il ricalcolo bottom-up per livelli con query aggregata è sia corretto (rispetta la semantica
di max bottom-up) sia efficiente (O(k) query dove k è la profondità dell'albero
invece di O(n) per numero di nodi). Il risultato è un sistema che regge workflow lunghi,
retry frequenti, cancellazioni massive di sottoalberi e propagazioni indirette senza compromettere
la correttezza dei dati.