Il controllo di flusso per stream basato su credito è il meccanismo che limita i dati in transito da un capo all’altro e rende sicuri gli stream illimitati (video dal vivo in ingresso o in uscita): un mittente non può mai portarsi avanti rispetto al suo destinatario più della finestra concessa.
Le leggi
- L9: L’unità di credito è un frame CHUNK. Un mittente mantiene una finestra
per stream inizializzata all’
initial_creditnegoziato (predefinito 32 chunk ≈ 8 MiB con ilmax_chunkpredefinito); ogni CHUNK inviato la decrementa; a zero il mittente DEVE attendere (in modo asincrono o bloccante, a seconda del linguaggio). - L10: L’endpoint destinatario concede credito inviando un frame CREDIT dopo aver consumato dei chunk; le concessioni vengono raggruppate (soglia consigliata: consumati ≥ finestra/2). Corollario “svuota prima di bloccarti”: una parte che detiene concessioni in sospeso al di sotto della soglia di lotto DEVE svuotarle prima di QUALSIASI attesa bloccante: una recv con input vuoto, un tick di polling silenzioso o un’acquisizione di credito a valle (i forwarder concedono a monte e acquisiscono a valle; bloccarsi su uno dei due mentre si detengono concessioni può affamare proprio il produttore che sbloccherebbe la catena). Le soglie di lotto derivano dalla negoziazione del collegamento del destinatario, mentre la finestra del mittente può provenire da un collegamento diverso, quindi un mittente può legittimamente fermarsi al di sotto della soglia di lotto del destinatario, e solo lo svuotamento garantisce l’avanzamento con qualsiasi discrepanza tra finestra e lotto. Il credito concesso è una garanzia di buffer: il destinatario deve mantenere una capacità di coda ≥ alle sue concessioni in essere, così i frame di dati non possono mai bloccare la consegna dei frame di controllo. Questo è l’invariante di assenza di deadlock che consente alle implementazioni con code limitate (Go, Python) di partecipare in sicurezza; il riferimento Rust mantiene canali interni illimitati e impone invece la finestra tramite conteggio, il che produce la stessa legge visibile sul wire.
- L11: Il credito è da un capo all’altro. Gli intermediari (runtime dell’host, relay switch) inoltrano i frame CREDIT lungo il percorso della richiesta esattamente come Cancel; non originano né assorbono mai credito.
- L12: Ricevere un CHUNK oltre la finestra concessa è un errore di protocollo
fatale: il destinatario fa fallire la richiesta con
CREDIT_VIOLATION. - L13: Un terminale di richiesta (END/ERR/Cancel) rilascia tempestivamente tutti coloro che attendono credito con un errore. Un mittente bloccato in attesa di credito non deve mai restare appeso.
- L14: Il credito si applica in entrambe le direzioni: agli stream di input motore→cartuccia
(le concessioni di consumo dell’
InputStreamdella cartuccia), agli stream di output cartuccia→motore (le concessioni di consumo dell’esecutore) e agli stream di argomenti / risposte peer↔peer. LOG, ERR, END, STREAM_START e STREAM_END non sono mai soggetti a credito: avanzamento ed errori scorrono sempre, anche quando le finestre dei dati sono esaurite. - L15: Un endpoint che invia e riceve sulla stessa richiesta DEVE servire i frame in ingresso in modo concorrente all’invio; un ciclo sequenziale invia-poi-raccogli va in deadlock nel momento in cui un handler produce output mentre il suo input sta ancora scorrendo.
Elementi sul wire
| elemento | valore |
|---|---|
| tipo di frame CREDIT | 13 (non di flusso: nessun seq, aggira i buffer di riordinamento) |
chiave credit |
19 (concessione u64, in unità di CHUNK) |
| destinazione della concessione | (rid, stream_id); una concessione senza stream_id accredita l’unico stream registrato della richiesta |
meta credit_dir |
"request" o "response", che indica lo stream di quale lato viene accreditato. OBBLIGATORIO: gli host instradano le concessioni in base a esso (una concessione response viaggia verso l’handler, una concessione request verso il richiedente); la sola chiave (xid, rid) è ambigua per le chiamate peer ad anello su sé stesse. Una concessione senza direzione è uno scarto non instradabile, conteggiato. |
initial_credit |
negoziato nei limiti di HELLO / RelayNotify, minimo elemento per elemento, predefinito 32; una finestra che si negozia a zero fa fallire l’handshake |
Il modello dimostrato
Che cosa fa una finestra di credito è enunciato e dimostrato in
formal/CapDAG/Bifaci/Credit.lean (le leggi in Laws/Credit.lean), e ogni
mirror esegue il codice generato da esso, quindi nessun runtime calcola una finestra per
conto proprio:
- il lato del mittente,
Gate:acquire(preso, attesa, o chiuso con il motivo),grant,close; - il lato del destinatario,
Window:arrive(accettato, o una violazione quando non resta nulla di quanto concesso),consume(la concessione ora dovuta: una volta consumata metà della finestra, almeno 1),flush(qualsiasi cosa sia in sospeso),continued(un chunk che si limita a continuare un elemento, concesso di nuovo subito); negotiate(la proposta più piccola; lo zero viene rifiutato) egrantTarget(lo stream indicato da una concessione, o l’unico della richiesta quando non ne indica nessuno).
Su un collegamento con un mittente e un destinatario è dimostrato che il credito si
conserva (Conserved: ciò che il mittente può inviare, ciò che è in transito, ciò che il
destinatario detiene non consumato e ciò che ha consumato ma non ancora concesso
sommati danno sempre la finestra), che un mittente che segue il gate non supera mai il
destinatario (never_overruns, L12), che il destinatario non detiene mai più della
finestra (buffer_bounded, L10), e che un mittente bloccato a zero è sempre
atteso da una concessione che il destinatario deve o da un chunk ancora da consumare
(blocked_sender_is_waited_for).
Gli oggetti propri di ogni runtime vengono rieseguiti rispetto agli script del modello
(formal/conformance-bifaci.json), così anche ciò che sta
tra le decisioni generate e il wire (contatori, contenitori, l’ordine
in cui le cose vengono fatte) è vincolato al modello.
Mappa dell’implementazione di riferimento (Rust)
capdag-rs/src/bifaci/credit.rs:CreditGate(la finestra del mittente:acquire/blocking_acquire/grant/close),CreditWindow(quella del destinatario:arrive/consumed/flush/continued) eCreditRouter(consegna i frame CREDIT in ingresso ai gate registrati;close_requestrilascia chi attende su una richiesta, L13). Ognuno mantiene lo stato del modello e aggiunge solo l’attesa e il risveglio: mutex + notify in Rust,threading.Conditionin Python, un canale broadcast in Go, una coda di continuation in Swift.- Output della cartuccia:
OutputStream::write/emit_list_item/emit_cborsonoasynce acquisiscono un credito per chunk;blocking_write/blocking_emit_list_itemservono i thread FFI;StreamSender(emettitore distaccato) acquisisce in modo bloccante. I gate si registrano presso il router del runtime allastart(); il ciclo di esecuzione instrada loro i frame CREDIT in ingresso e li chiude al completamento dell’handler o a un Cancel. - Input della cartuccia: il demux tiene traccia di una finestra per stream
(
initial_credit+ concessioni);InputStream::recvemette concessioni raggruppate man mano che l’handler consuma (finestra/2). Un chunk oltre la finestra emerge come errore di streamCREDIT_VIOLATION(L12). - Motore:
send_one_streamregistra un gate per stream di input e acquisisce per chunk;collect_terminal_output/TerminalOutputconcedono per i chunk di risposta consumati e instradano i frame CREDIT in ingresso ai gate di invio (CreditPlumbing).execute_faninesegue l’alimentazione dell’input e la raccolta della risposta in modo concorrente (L15). - Catene in pipeline (
forward_frames, sia nell’esecutore di riferimento sia nel motore MachineFabric): ogni passaggio acquisisce dalla finestra della cap a valle e concede alla cap a monte man mano che consuma.
Che cosa non è soggetto a controllo di flusso
I frame LOG/di avanzamento (L14). Una finestra di dati ferma non deve mai zittire la segnalazione dell’avanzamento. Il volume dei LOG è illimitato e osservabile tramite i contatori degli scarti.
La corsa alla chiusura e come vengono classificati i frame non instradabili
Il credito attraversa il wire nella direzione opposta ai chunk che accredita,
quindi una concessione può sempre essere in transito quando arriva il terminale della richiesta (due
socket, direzioni opposte), e nessun controllo lato mittente può chiudere questa finestra.
I destinatari distinguono quindi ogni frame non instradabile in due modi, e i
due esiti sono eventi di TIPO DIVERSO, conteggiati in famiglie diverse
(vedi il modulo stats: StragglerCounters vs DropCounters):
- Ritardatario benigno successivo al terminale: il RID del frame appartiene a una
richiesta TERMINATA DA POCO: ha incrociato END/ERR in transito. È la
normale corsa alla chiusura, legittima per il protocollo, del controllo di flusso basato su credito (una
concessione, un terminale duplicato, un LOG ritardatario, un chiamato che ha inviato END
prima di svuotare il proprio input). Nulla è andato storto e nessun dato è andato perso,
e il frame è irrilevante. NON è uno scarto: viene conteggiato nei contatori
dei ritardatari, per tipo di frame (così una traccia dice “credito in ritardo” vs “chunk
in ritardo”), registrato a livello debug e indicato come benigno in ogni superficie
di statistiche (
stragglersaccanto adropsnegli snapshot e nelle tracce,stragglers_totalaccanto adrops_totalnegli heartbeat). - Scarto
no_route: il runtime non ha mai conosciuto il RID. Una vera anomalia di instradamento per cui vale la pena dare l’allarme: conteggiata nei contatori degli scarti (per motivo × tipo di frame) e registrata come avviso.
Il discriminante è l’anello limitato delle richieste terminate di recente che ogni runtime
di instradamento già mantiene (64 voci: l’anello della tabella delle richieste del relay switch,
l’anello dei rilasci al terminale degli host). La finestra della corsa è di millisecondi, quindi l’espulsione
dall’anello non può classificare male una corsa reale; un frame patologicamente in ritardo invecchia
fino a ricadere in no_route, dove sta bene qualcosa di così obsoleto. Le voci di instradamento
espulse dal GC non sono nell’anello: una voce espulsa non ha mai visto il suo
terminale, quindi i suoi frame sono una reale perdita di instradamento.
Esistono anche controlli lato mittente, e sono esattamente due: il relay switch restituisce un errore bloccante
UnknownRequest ai chiamanti diretti dell’API (il motore deve osservare che una
richiesta non esiste più e smettere di inviare), e il mittente del credito a monte del motore
tratta esattamente quell’errore come irrilevante, perché i crediti per una richiesta conclusa
non hanno significato, e far fallire un corpo per uno di essi trasformerebbe la corsa alla chiusura in
un falso fallimento dell’esecuzione. Ogni altro fallimento di invio resta bloccante.
Overrun: la categoria della cattura dal vivo
Una sorgente di cattura dal vivo non può fermarsi: il dispositivo continua a produrre che qualcuno abbia concesso credito o no. Quando la finestra del consumatore resta indietro rispetto alla realtà, i frame devono andare da qualche parte, e quel da qualche parte è definito: mai “buffer per sempre” e mai una perdita silenziosa.
L’overrun è la terza categoria ristretta di contabilizzazione, distinta sia dagli scarti sia dai ritardatari perché significa una terza cosa:
| Categoria | Significato | Natura |
|---|---|---|
| drop | un frame in transito è andato perso perché qualcosa è andato storto | segnale di fallimento |
| straggler | un frame ha legittimamente incrociato il terminale della sua richiesta | benigno, previsto |
| overrun | un input in tempo reale è stato scartato al bordo della cattura perché la finestra del consumatore era in ritardo | proprietà intrinseca della cattura dal vivo; visibile, regolata da una politica, mai un fallimento per impostazione predefinita |
Il meccanismo: la catena completa di contropressione è credito sul wire → output dell’op → consumo dell’input dell’op → il canale di consegna limitato del feed → l’alimentatore del provider → il ring buffer della cattura → il bordo della cattura. Ogni stadio tranne l’ultimo si blocca; l’ultimo non può farlo, quindi applica la politica dichiarata del feed:
drop-oldest(predefinita): espelle la voce più vecchia dell’anello, conta un overrun e marca l’elemento consegnato successivo con un indicatoregap({dropped, duration_us}; vedi 12.4 §Feed dal vivo). I consumatori in tempo reale preferiscono dati freschi a dati completi.fail: termina il feed con un errore classificato (FEED_OVERRUN, classeresource). Le pipeline che hanno bisogno di ogni frame lo scelgono esplicitamente.
Contabilizzazione: gli overrun vengono conteggiati per feed e per l’intero runtime
(overruns_total viaggia nei meta dell’heartbeat accanto a drops_total e
stragglers_total), e ogni salto è visibile in banda tramite i meta dell’elemento, così una
cap a valle, la traccia e l’operatore vedono tutti esattamente che cosa è andato perso
e quando, indicato per quello che è: il consumatore più lento della realtà,
non un malfunzionamento.
Il registro delle statistiche è una finestra
Il credit_outstanding per stream negli snapshot delle statistiche di protocollo è la
FINESTRA RESIDUA dello stream così come osservata da quel runtime: inizializzata con
l’initial_credit della richiesta negoziato dalla destinazione quando viene creata la voce
di registro dello stream, meno uno per ogni CHUNK in entrambe le direzioni (i chunk di uno stream
scorrono tutti in un verso; le sue concessioni nell’altro), più il conteggio di ogni concessione.
I frame in uscita originati dal motore vengono registrati nello stesso registro sul percorso
di invio dello switch. Non negativo nel funzionamento sano; un valore negativo
significa che il produttore ha superato la propria finestra. Solo diagnostico: sono gli endpoint a detenere le
finestre autorevoli.