In questa sezione

Multiplexer di instradamento consapevole delle cap, posto tra l’engine e i relay master.

RelaySwitch

Il RelaySwitch è il componente centrale di instradamento della rete CapDAG. Si collega a una o più coppie RelaySlave/RelayMaster (ciascuna sostenuta da un CartridgeHostRuntime che gestisce una o più cartucce) e instrada le richieste al master corretto in base alla corrispondenza dei cap URN.

Tutti i metodi prendono &self (non &mut self). La mutabilità interna (RwLock, Mutex, AtomicU64, AtomicBool) consente a più esecuzioni di DAG concorrenti di instradare contemporaneamente frame attraverso lo switch.

pub struct RelaySwitch {
    masters: RwLock<Vec<MasterConnection>>,
    cap_table: RwLock<Vec<(String, usize)>>,   // cap_urn → indice del master
    requests: RwLock<RequestTable>,            // stato unificato per richiesta (L7)
    drops: Arc<DropCounters>,                  // scarti contati (L8)
    xid_counter: AtomicU64,
    live_cap_fab: RwLock<LiveCapFab>,
    cap_registry: Arc<FabricRegistry>,
    // ...
}

Stato unificato delle richieste. Tutto lo stato per richiesta risiede in un’unica RequestTable (capdag-rs/src/bifaci/request_state.rs): una voce per ogni richiesta in volo che contiene il suo instradamento, l’origine, il marcatore peer, i figli per l’annullamento a cascata, il canale di risposta, le statistiche di flusso per stream e i timestamp, più l’indice secondario rid→xid. Una richiesta viene registrata una sola volta (una doppia registrazione è una violazione del protocollo e fallisce in modo netto) e terminata una sola volta (terminate(key, kind) con kind End | Err | MasterDied, oppure terminate_cancelled(key, reason); una terminazione Cancelled porta l’attribuzione del Cancel (codice terminale, classe, motivazione), e il riepilogo che lascia nell’anello delle richieste terminate le registra). Dopo la terminazione non resta alcuno stato per la chiave, in modo atomico. Il frame terminale viene inoltrato usando lo stato che terminate restituisce, così il rilascio dello stato e la consegna del terminale non possono discordare (L6).

Sorgente: capdag-rs/src/bifaci/relay_switch.rs.

Inizializzazione

La creazione di un RelaySwitch si svolge in due fasi:

Fase 1: verifica di ciascun master:

Per ogni connessione su socket Unix:

  1. Dividerla nelle metà di lettura e di scrittura.
  2. Leggere il primo frame, che deve essere un RelayNotify che porta il manifest aggregato e i limiti dell’host. 2a. Inviare la Policy corrente dello switch, prima che qualsiasi altra cosa raggiunga quell’host. Lo switch viene costruito con una policy e non esiste mai senza.
  3. Eseguire la verifica dell’identità: inviare una REQ per CAP_IDENTITY (cap:effect=none) con un nonce, verificare la risposta con l’eco.
  4. Durante la verifica possono arrivare altri frame RelayNotify (man mano che il CartridgeHostRuntime scopre e collega le sue cartucce). Questi aggiornano il manifest del master.
  5. Mettere da parte il lettore per la fase 2.

Fase 2: avvio dei lettori:

Dopo che tutti i master sono stati verificati, viene avviato un task di lettura (tokio::spawn) per ciascun master. Questi task leggono i frame dal socket e li inviano attraverso un canale mpsc alla logica di instradamento dello switch.

Questo schema a due fasi garantisce che la verifica dell’identità avvenga in sequenza per ciascun master (senza interferenze dei task di lettura), mentre i task di lettura partono solo dopo che l’intero switch è stato verificato.

Sorgente: relay_switch.rs (RelaySwitch::new).

Instradamento delle cap

flowchart TD
    REQ["REQ(cap_urn)"] --> PARSE["Analizza il cap URN"]
    PARSE --> MATCH["Confronta con l'insieme<br/>di cap di ciascun master<br/>(is_dispatchable)"]
    MATCH --> RANK{Più corrispondenze?}
    RANK -->|Sì| SPEC["Ranking per specificità"]
    RANK -->|No| SKIP
    SPEC --> SKIP{Master in salute?}
    SKIP -->|No| ERR["RelaySwitchError::NoHandler"]
    SKIP -->|Sì| XID["Assegna l'XID<br/>(incremento atomico)"]
    XID --> REC["Registra (XID, RID)<br/>nelle tabelle di instradamento"]
    REC --> FWD["Inoltra la REQ al<br/>master selezionato"]

Quando arriva un frame REQ (dall’engine o da una cartuccia peer), lo switch lo instrada:

  1. Analizza il campo cap (chiave 10) come cap URN.
  2. Confronta con l’insieme di capacità di ciascun master usando is_dispatchable(candidate, request) (vedi Dispatch). Il cap URN del candidato deve accettare il tipo di input della richiesta (controvarianza) e produrre un tipo di output compatibile (covarianza).
  3. Applica il ranking se più master corrispondono. Il ranking per specificità (vedi Ranking) seleziona il candidato più specifico.
  4. Salta i master non in salute: i master marcati come non in salute sono esclusi dall’instradamento.
  5. Assegna l’XID: incrementa atomicamente xid_counter e assegna il nuovo valore come routing_id della richiesta.
  6. Registra l’instradamento: registra (XID, RID) nella RequestTable con la sua RoutingEntry; la tabella registra anche RID → XID.
  7. Inoltra: invia la REQ (con l’XID) allo scrittore del socket del master selezionato.

Se nessun master in salute può gestire la cap, lo switch restituisce RelaySwitchError::NoHandler.

Sorgente: relay_switch.rs.

Assegnazione degli XID

Gli XID (ID di instradamento, chiave 13 nei frame) sono interi senza segno assegnati dal RelaySwitch ai confini di instradamento. Hanno un unico scopo: permettere allo switch di instradare i frame di continuazione al master corretto.

  • REQ in arrivo dall’engine: non ha XID. Lo switch ne assegna uno e registra l’associazione.
  • Frame di continuazione (CHUNK, STREAM_START, ecc.): portano l’XID della REQ originale. Lo switch cerca (XID, RID) nella RequestTable per trovare la destinazione.
  • Frame di risposta dal master: portano l’XID. Lo switch usa l’origin della voce per stabilire dove inviarli (di ritorno all’engine o al master della cartuccia richiedente).

Le cartucce non vedono mai direttamente gli XID; sono puramente infrastruttura. La REQ di una cartuccia non ha XID; il CartridgeHostRuntime la inoltra e lo switch ne aggiunge uno.

Sorgente: relay_switch.rs, frame.rs (campo routing_id).

Instradamento delle richieste peer

Quando la Cartuccia A chiama una cap fornita dalla Cartuccia B:

  1. La REQ della Cartuccia A arriva allo switch (tramite CartridgeHostRuntime → RelaySlave → RelayMaster) senza XID.
  2. Lo switch assegna un XID e la instrada al master della Cartuccia B con la stessa logica di corrispondenza delle cap.
  3. La richiesta viene registrata con is_peer = true, che ne guida la pulizia.
  4. L’origin della voce registra che questa richiesta proviene dall’indice del master della Cartuccia A (non dall’engine).
  5. I frame di risposta della Cartuccia B tornano attraverso lo switch al master della Cartuccia A.

La semantica di pulizia delle richieste peer è diversa da quella delle richieste avviate dall’engine: lo switch deve attendere la risposta completa (l’END della Cartuccia B) prima di ripulire, perché la risposta deve raggiungere la Cartuccia A.

Sorgente: relay_switch.rs.

Tracciamento delle richieste

Lo switch conserva tutto lo stato per richiesta nella RequestTable: un RequestState per ogni richiesta in volo, con chiave (XID, RID), più un indice RID → XID per i frame che portano soltanto un RID (frame di continuazione).

Campo di RequestState Valore Scopo
routing RoutingEntry (master di origine + destinazione) Instradare i frame di continuazione al master giusto.
origin Option<usize> (indice del master o None) Dove tornano i frame di risposta. None = chiamante esterno (engine).
external_channel mpsc::UnboundedSender<Frame> Per le chiamate execute_cap: consegnare i frame di risposta al codice chiamante.
is_peer bool Marca le invocazioni peer (per la pulizia).
children chiavi delle richieste figlie Chiamate peer figlie, per l’annullamento a cascata.
streams statistiche di flusso per stream Contatori di frame, byte, chunk e crediti.

I frame terminali (END, ERR) attivano terminate(key, kind), un’unica rimozione atomica. Un frame che arriva senza stato di instradamento viene contato e classificato per ciò che è effettivamente, mai un errore di protocollo e mai in silenzio (L6/L8): se il suo RID si trova nell’anello limitato delle richieste terminate di recente della tabella (recently_terminated_rid), il frame ha incrociato in volo il terminale della sua richiesta: è un ritardatario post-terminale INNOCUO, l’ordinaria corsa di chiusura del controllo di flusso basato sui crediti, contato per tipo di frame nei contatori dei ritardatari e indicato come innocuo (NON è uno scarto; nulla è andato storto). Un RID che la tabella non ha mai conosciuto conta come SCARTO no_route, il vero allarme di anomalia di instradamento. Questa classificazione (account_unrouted_frame) si applica in tutti e tre i punti non instradabili: un terminale duplicato per una richiesta già rilasciata, un frame di risposta senza stato di instradamento e una continuazione senza stato di instradamento.

Il ciclo di vita delle richieste che la tabella implementa (una richiesta viene registrata una volta, terminata una volta, non lascia stato dietro di sé, e un frame senza stato di instradamento è un ritardatario esattamente quando la sua richiesta è terminata di recente) è enunciato e dimostrato in formal/CapDAG/Bifaci/Request.lean (leggi in Laws/Request.lean). Le decisioni vengono generate da esso in ogni mirror: quali tipi di frame terminano una richiesta e come (terminalOf), dove va un frame senza instradamento (dispose), che cosa fa un frame al registro dei crediti di uno stream (ledger) e che cosa fa lo scrittore di un frame una volta concluso il suo flusso (write). La RequestTable di ciascun mirror viene ripercorsa passo per passo confrontandola con la tabella del modello.

La registrazione inizializza il registro di flusso di ogni richiesta con l’initial_credit negoziato del master di destinazione (master_initial_credit), e il percorso di invio rivolto all’engine (send_to_master) registra i frame di continuazione/controllo in uscita nello stesso registro, così il credit_outstanding per stream nell’istantanea delle statistiche è la finestra rimanente dello stream (vedi Controllo di flusso).

Annullamento e arresto

Due controlli lato host agiscono su una richiesta viva, e non sono la stessa cosa:

  • cancel_request(rid, reason): il percorso di interruzione. Termina la richiesta come annullata con la motivazione (codice terminale, classe e motivazione registrati nel suo riepilogo; CANCELLED e nessuna classe per una motivazione non attribuita), invia il frame Cancel (attribuzione in meta, force_kill) al master di destinazione, si propaga a cascata a ogni chiamata peer figlia con la stessa motivazione e consegna l’ERR terminale al canale di risposta esterno con le parole della motivazione stessa: CANCELLED/user, ABORTED_COLLATERAL con la classe del fallimento d’origine, ABORTED con quella dell’host. cancel_all_requests(reason) fa questo per ogni richiesta originata dall’engine. La motivazione è un’attribuzione facoltativa, mai una precondizione.
  • stop_request_feeds(rid): il percorso di tap-off, la chiusura dell’ingresso (15.2 §Le esecuzioni si arrestano). Invia un frame CloseStream alla destinazione senza toccare lo stato della richiesta lato host: la cartuccia chiude i tap sui feed della richiesta e la richiesta termina naturalmente con END dopo lo svuotamento, così l’instradamento, l’inoltro e ogni cap a valle restano vivi. Restituisce se la richiesta era viva e l’arresto è stato inviato; una richiesta che lo switch non conosce non viene arrestata, e il chiamante non deve affermare che lo sia stata. La prova che l’input è poi terminato è il terminale della richiesta, osservabile tramite recent_terminal_of_rid / await_request_terminal; bifaci::live_feed::stop_feed_requests costruisce il verdetto di arresto di un host esattamente da quella prova.

Sorgente: relay_switch.rs.

Policy

set_policy(policy) sostituisce la Policy corrente dello switch e la invia a ogni master, dove ciascun host la passa alle sue cartucce. Impostare la policy già in vigore non invia nulla. Lo switch viene costruito con la sua prima policy (RelaySwitch::new(sockets, policy)), così non c’è mai un momento in cui un host è collegato e non ne è stato informato. L’engine chiama set_policy ogni volta che l’operatore cambia l’impostazione di rete.

Sorgente: relay_switch.rs.

Aggiunta dinamica di master

add_master() collega un nuovo master durante l’esecuzione:

  1. Accetta una nuova connessione su socket Unix.
  2. Legge il RelayNotify, invia la Policy corrente, esegue la verifica dell’identità.
  3. Avvia un task di lettura.
  4. Unisce le capacità del nuovo master nella tabella delle cap aggregata.
  5. Ricostruisce il LiveCapFab per la ricerca dei percorsi.

Questo consente di collegare a caldo nuovi host senza riavviare lo switch.

Slot, id e collegamento

Ogni master occupa uno slot con un id stabile scelto dal chiamante ("external-cartridges", "cartridge-host", …). L’indice dello slot è la chiave di instradamento per la tabella delle richieste e per la tabella delle cap, quindi non cambia mai: quando l’host dietro uno slot muore e si riconnette, add_master con lo stesso id ricollega il nuovo socket sul posto, all’indice esistente.

Se uno slot può essere (ri)collegato lo decide il suo socket, mai il suo flag di salute: uno slot è collegato finché il suo task di lettura è vivo, e scollegato una volta che il lettore è uscito (EOF, errore di I/O) o che handle_master_death ha dichiarato morta la connessione, il che ritira anche il lettore. Uno slot che è soltanto non in salute (sonda dell’identità in sospeso o fallita) con il socket aperto è ancora collegato, e ricollegarvisi sopra interromperebbe un lettore vivo e lascerebbe isolato l’host all’altro capo.

  • add_master(id, socket) su uno slot collegato è un errore di protocollo (“already attached to a healthy / attached-but-unhealthy slot”); lo stesso master non deve essere collegato due volte. Su uno slot scollegato lo ricollega.
  • slot_accepts_attach(id) risponde alla stessa domanda senza consumare un socket. Un host che si (ri)connette a ogni evento del piano di controllo (il canale delle cartucce dell’engine che si ristabilisce, o il ricaricamento di un client) deve chiederlo prima di comporre il socket del relay: un socket composto verso uno slot collegato è una connessione duplicata che l’altra parte deve rifiutare, e add_master può scoprirlo solo dopo aver letto il socket.

Il lato host della stessa regola: un host relay accetta un solo socket dell’engine per sessione e rifiuta (chiude) un secondo socket mentre il primo è aperto. Una connessione duplicata non è mai un avvicendamento di sessione e mai un motivo per abbattere l’host e ogni cartuccia che esegue.

Sorgente: relay_switch.rs (add_master, slot_accepts_attach, handle_master_death_with_reason).

Salute dei master

Ogni master tiene traccia del proprio stato di salute:

pub struct MasterHealthStatus {
    pub index: usize,
    pub healthy: bool,
    pub cap_count: usize,
    pub connected_seconds: u64,
    pub last_error: Option<String>,
}

Un master diventa non in salute quando:

  • Il suo task di lettura incontra un errore di I/O (socket chiuso, errore di decodifica CBOR).
  • La verifica dell’identità fallisce durante l’inizializzazione, oppure una sonda differita dell’identità in fase di esecuzione è in sospeso o è fallita.

La salute è una proprietà dell’instradamento. Se il socket dello slot sia ancora collegato è una questione separata (vedi Slot, id e collegamento sopra): un master non in salute il cui socket è aperto è ancora collegato e non vi si può ricollegare sopra; una connessione morta scollega lo slot, qualunque fosse il suo stato di salute.

I master non in salute sono esclusi dall’instradamento delle cap, e le loro capacità non vengono considerate nella corrispondenza dei frame REQ. Il flag healthy è un AtomicBool su MasterConnection, controllato a ogni decisione di instradamento.

Sorgente: relay_switch.rs (MasterHealthStatus, MasterConnection::healthy).

Aggregazione delle capacità

capabilities() restituisce l’unione delle capacità di tutti i master in salute come manifest JSON aggregato. È la visione dell’engine di ciò che l’intera rete di cartucce può fare.

Lo switch espone anche:

  • get_reachable_targets(): interroga il LiveCapFab per trovare quali media URN di output sono raggiungibili da un dato input.
  • find_paths_to_exact_target(): trova le macchine che trasformano un input in un output specifico.

Questi metodi sono usati dal planner (vedi Planner) per trovare percorsi di esecuzione attraverso il grafo delle capacità.

Sorgente: relay_switch.rs.

Statistiche di protocollo

RelaySwitch::protocol_stats() restituisce un’istantanea serializzabile: la fase di ogni richiesta viva (created/streaming), l’età, il tempo di inattività, i contatori per stream di frame/byte/ chunk/crediti e i figli; un anello limitato di riepiloghi delle richieste terminate di recente (kind, con il codice terminale, la classe e la motivazione dell’annullamento quando il kind è cancelled; durata, totali di flusso); i totali di registrazione e di terminazione per kind sull’intera durata; e i contatori degli scarti per motivo. Il runtime dell’host espone la stessa forma tramite protocol_stats() (contatori degli scarti + dimensioni delle tabelle di instradamento + totali della GC), inviata allo switch dentro ogni ripubblicazione delle statistiche via RelayNotify; le cartucce riportano i loro totali di scarti a ogni heartbeat.

Lo switch CONSERVA le statistiche dell’host annunciate da ciascun master (mappa hosts nell’istantanea, con chiave l’id del master), e l’host acquisisce il drops_total dell’heartbeat di ciascuna cartuccia in runtime_stats.protocol_drops_total del record di inventario (assente fino alla prima lettura). Nulla sulla superficie L8 viene analizzato e poi scartato: ogni lettura che una parte produce è osservabile da un capo all’altro (GetProtocolStats del gRPC dell’engine, i flow_states dell’esecuzione e l’inventario delle cartucce).

Equivalente Swift

La classe RelaySwitch in capdag-objc/Sources/Bifaci/RelaySwitch.swift fornisce lo stesso comportamento di instradamento per gli host dell’engine basati su Swift. La semantica dell’instradamento (corrispondenza delle cap, assegnazione degli XID, tracciamento dei peer) è identica.