Con una coda il rifiuto diventa attesa — poi mi sono accorto che stavo ancora assegnando il lavoro
Quinto articolo della serie sul sandbox per agenti. Una coda rende esplicita l’attesa e l’utente passa da «nessuna capacità» a «sei il settimo in fila». Rileggendo il codice ho scoperto che non avevo costruito «lo sportello chiama il numero successivo», ma «un responsabile di sala chiede a ogni sportello se è libero».
L’articolo precedente lasciava due buchi: nessuno ricorda quale lavoro è in sospeso e un pool pieno sa dire solo «nessuna capacità disponibile».
Sono lo stesso buco: nel sistema manca un elenco delle cose in attesa.
Il distributore di numeri in banca
Le file fisiche hanno risolto la questione da tempo: si prende un numero all’ingresso, ci si siede, si aspetta di essere chiamati. Il distributore non si preoccupa di quale sportello sia libero: il suo compito è solo metterti in fila.
Nel codice è una coda. Il compito del dispatcher si riduce quasi a nulla — accetta il task, lo mette in coda, comunica la posizione:
r.lpush(QUEUE_NAME, json.dumps(task_data)) # in coda, è tutto qui
return {
"message": "task in coda",
"position_in_queue": r.llen(QUEUE_NAME), # quanti ci sono davanti a te
}
Non ha più bisogno di sapere quanti container esistano, né chi sia occupato. Nei picchi l’utente smette di leggere «nessuna capacità disponibile» e legge «sei il settimo in fila»: la stessa attesa, un’esperienza completamente diversa — respinti sulla porta, oppure già dentro la fila.
Dall’altra parte, un processo scheduler sorveglia la coda:
while True:
if r.llen(QUEUE_NAME) > 0: # c’è lavoro in attesa
worker_name = await find_idle_worker() # cerca un container libero
if worker_name:
task = r.rpop(QUEUE_NAME) # estrae un task
await dispatch_task(worker_name, task) # glielo consegna
await asyncio.sleep(1)
Funziona: niente si perde, i picchi si accodano, l’utente riceve una posizione invece di un errore.
Poi ho riletto quel codice
Credevo che questo passo trasformasse il sistema da spingere il lavoro a farlo prendere — la mossa da manuale: invece di un componente centrale che rifila un task a un worker scelto, chi si libera va a prendersi il task successivo.
Ma si guardi cosa fa quel codice. find_idle_worker() chiede a ogni container se è libero, poi dispatch_task() gli consegna il lavoro.
È ancora spinta. Ho solo spostato chi spinge dal banco d’ingresso al retro.
Con l’analogia della banca: volevo costruire «lo sportello finisce e chiama il numero successivo». Ho costruito «un responsabile di sala chiede a ogni sportello se è occupato e poi accompagna il cliente». La differenza non è stilistica:
| Il responsabile assegna (ciò che ho fatto) | Lo sportello chiama (ciò che andava fatto) | |
|---|---|---|
| Due possono ricevere lo stesso task | Sì — c’è un vuoto tra il chiedere e l’assegnare | No — estrarre dalla coda è di per sé esclusivo |
| Serve interrogare tutti | Sì, e peggiora al crescere del numero | No, chi è libero si presenta |
| Il centro deve sapere chi è libero | Sì | No, al centro basta la coda |
Quindi i due difetti del terzo articolo — la corsa tra il chiedere e l’assegnare e il costo di interrogare tutti — sono entrambi ancora lì. Si sono solo spostati dalla vista dell’utente al retrobottega. L’utente non vede più l’errore; il problema non si è mosso.
Il pull vero richiede una sola lettura bloccante fatta dal worker stesso (BRPOP in Redis): chi è libero va a prendersi un task, e prenderlo lo rende suo — nessun altro può prendere lo stesso. Niente lock, niente tabella di chi-è-libero, nessuna interrogazione da un processo centrale.
Ho scritto questa sezione invece di correggere il codice in silenzio perché è l’errore più utile della serie: «abbiamo aggiunto una coda» e «siamo passati al modello pull» sono due affermazioni diverse. La prima compra un cuscinetto; solo la seconda elimina la contesa nello scheduling. Io avevo dato per scontato che la prima portasse con sé la seconda.
Un’altra cosa non implementata
L’endpoint di invio accetta un campo:
task_data = {"task_id": task_id, "pages": pages, "is_vip": is_vip}
is_vip viaggia fino dentro la coda e poi — niente. Lo scheduler ha esattamente una coda FIFO e la serve in ordine. Priorità: campo accettato, logica mai scritta.
Lasciare spazio in un’interfaccia non è sbagliato di per sé, ma se non lo si dice sembra una funzione già esistente. Farlo davvero significa di solito due code, la VIP consultata prima della standard — e poi una regola che impedisca alla standard di morire di fame.
Cosa stabilisce questo passo
Quel che ha davvero portato: i picchi passano dal rifiuto all’attesa e i task non si perdono più. Dispatcher e worker sono ora del tutto disaccoppiati: il dispatcher non sa nemmeno quanti container esistano.
Quel che non ha portato: la contesa nello scheduling è ancora lì, solo nascosta.
L’ultimo articolo prende tutti i pezzi costruiti finora, li mette sotto carico e separa ciò che è davvero stabile da ciò che semplicemente non è stato spinto abbastanza — e spiega perché sull’isolamento duro mi sono fermato alla teoria.
In una riga: una coda compra un cuscinetto, non l’assenza di lock; consegnare al bordo la decisione su «chi lo fa» è la mossa che toglie davvero lavoro.
Codice: agent-sandbox-oss/lab5.
- Kubernetes
- AI Agent
- Code di task
- Redis
- Architettura