System Design Case Study

API Service — Resources & Nodes

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 Design Async Workflows PostgreSQL + ltree Transactional Outbox Tree Reconciliation Idempotency
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

Client HTTP Request Create / Update / Delete API Service Validate + Create Task mutation Resources (DB) PostgreSQL change detected Mapping Engine Resource → Nodes update tree Node Tree ltree (PostgreSQL) 202 Accepted Node Tree (dettaglio) Root A B A1 st:2 st:1 st:3

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.

st = status · agg = aggregated_status propagazione bottom-up Root st=1 agg=3 max(1, max(3, 1)) = 3 agg = 3 ↑ Nodo A st=2 agg=3 max(2, 3) = 3 Nodo B st=1 agg=1 foglia → agg = st Nodo A1 st=3 agg=3 (foglia) agg=3

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

Client POST /resources + Idempotency-Key API Service Validate + Idempotency check + Task 202 Accepted + task_id Client polls GET /tasks/{id} Message Queue q:short / q:medium / q:long Worker lease + heartbeat PIPELINE STAGES Accept stage 1 Mutation stage 2 Mapping stage 3 Topology stage 4 Properties stage 5 Aggregated Status stage 6 Finalizer stage 7 ✓

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

Stage N pipeline step COMMIT atomico nodes / resources outbox_ events stesso BEGIN … COMMIT polling Outbox Publisher legge & pubblica Broker message bus Se il sistema crasha dopo il COMMIT → l'evento rimane nella outbox e verrà pubblicato al prossimo polling

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)

resources 🔑 id (UUID) type payload (JSONB) created_at updated_at nodes 🔑 id (UUID) class path (ltree) 🌳 parent_id (FK → self) properties (JSONB) aggregated_status business_key created_at ltree + GiST idx parent_id resource_nodes 🔑 resource_id (FK) 🔑 node_id (FK) created_at FK resource_id FK node_id tasks 🔑 id (UUID) idempotency_key payload_hash status lease_owner lease_expires_at heartbeat_at outbox_events 🔑 id (UUID) aggregate_type aggregate_id event_type payload (JSONB) published_at scope_generations 🔑 scope_id generation (bigint) updated_at scope_type

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

Fase 1 Eliminazione Nodes orfani Calcola orfani radice → DELETE path <@ root Fase 2 Inserimento nuovi Nodes Ordina parent-first → business key → UPSERT Fase 3 Delta sync mapping Cancella solo rimossi · inserisce solo nuovi Fase 4 Impatto diretto Nodes con Resources cambiate + nuovi Nodes Fase 5 Impatto indiretto (Node Config) Chiusura transitiva grafo dipendenze classi Fase 6 Properties via Engine Topological sort → bulk load → Engine → persist Fase 7 aggregated_status bottom-up status_area → MAX aggregato per livello ↑

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).

Classe impattata direttamente Classe raggiunta transitivamente C class C E class E B impattata A transitivo D transitivo A2 transitivo dipende da chiusura transitiva 🎯 Impatto iniziale su B → propaga ad A, D, A2

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.

Direzione ricalcolo foglia → intermedio → radice (bottom-up) STEP 1 — foglie STEP 2 — intermedi STEP 3 — radice Nodo A1 (foglia) status = 3 → agg = 3 agg=3 ↓ Nodo A agg recalc = max(2, 3) = 3 Nodo B agg = 1 (foglia) agg↓ agg↓ Root agg recalc = max(own, 3, 1) = ? SELECT parent_id, MAX(aggregated_status) GROUP BY parent_id — per ogni livello

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.