Scalabilità e recovery#
Topologia#
Più istanze di manent condividono lo stesso Postgres e prelevano i deploy dalla stessa coda. API e worker si possono scalare separatamente:
manent-server api # N istanze API dietro un load balancer
manent-server worker # M worker, ciascuno con un executor_id diverso
flowchart LR
LB[Load balancer] --> A1["API<br/>manent-server api"]
LB --> A2["API<br/>manent-server api"]
A1 --> PG[("Postgres<br/>(database DBOS)")]
A2 --> PG
PG --> W1["worker<br/>executor_id=manent-0"]
PG --> W2["worker<br/>executor_id=manent-1"]
PG --> W3["worker<br/>executor_id=manent-2"]
SQLite
SQLite va bene per sviluppo e per worker sulla stessa macchina. Con più nodi serve Postgres.
executor_id#
Ogni worker deve avere un dbos.executor_id univoco e stabile (default: l'hostname; DBOS__VMID ha la precedenza).
All'avvio un worker riprende solo i deploy rimasti in sospeso con il proprio ID, dallo step in cui si erano fermati. Se un nodo non torna con lo stesso ID, i suoi deploy restano in stato PENDING finché non vengono ripresi da un altro worker con la recovery, manuale o automatica. In Kubernetes conviene uno StatefulSet, i cui pod hanno nomi stabili (manent-0, manent-1, …): il chart Helm usa il nome del pod come executor_id.
Con il default, più worker sulla stessa macchina avrebbero tutti l'hostname come ID: va assegnato a ciascuno un ID proprio, stabile tra i riavvii.
DBOS__VMID=build01-w1 manent-server -c manent.yml worker
DBOS__VMID=build01-w2 manent-server -c manent.yml worker
Un worker che parte con l'ID di un altro ancora attivo (non arrestato e con un heartbeat più recente di recovery.stale_after) non si avvia. Altrimenti, al suo avvio, la recovery di DBOS riprenderebbe anche i deploy in esecuzione sull'altro, che verrebbero eseguiti due volte. Prima di rinunciare attende recovery.stale_after: è il caso di un riavvio dopo un crash, in cui il processo precedente non ha registrato l'arresto. L'errore indica l'host che usa l'ID.
Più region#
Con target in più region, i rilasci a ondate aggiornano una region (o un'AZ) alla volta. Con dbos.region_routing ogni worker dichiara la sua dbos.region ed esegue solo i passi sui target di quella region, dalla coda <queue>.<region>: SSH, download e controlli partono dalla rete della region. Il database resta uno, raggiunto da tutte le region.
Code dei plugin#
Alcuni plugin hanno bisogno di un runtime che l'immagine distroless di manent non ha: il plugin ansible richiede Python e ansible-core sulla macchina del worker che lo esegue. Date a un plugin del genere una coda propria e fatela servire solo a un gruppo di worker dedicato:
plugins:
ansible:
external: {command: /usr/local/bin/manent-plugin-ansible, sha256: "<sha256>"}
artifact_types: [ansible]
queue: ansible
- All'accodamento manent instrada il deploy sulla coda del plugin: in base al plugin del descrittore effettivo (
deploy.plugin, anche quando arriva dalla configurazione di ambiente) oppure, se in quel momento la configurazione di ambiente non è leggibile, in base al tipo di artefatto (artifact_types, quindi ogni tipo appartiene al più a un plugin con coda). La coda del plugin prevale su quella di un tenant. - Solo i worker che elencano il plugin in
dbos.pluginsregistrano e prelevano la sua coda, oltre alla propria. Appena un plugin ha una coda propria, ogni worker preleva solo dalle code che conosce (di default, dei tenant, della sua region, dei suoi plugin): DBOS conserva le code registrate nel database e, senza questo, un worker preleverebbe anche quella del plugin. Ovunque altrove, API compresa, il plugin è uno stub che conosce nome e tipi di artefatto: non viene mai avviato, quindi lì non servono né il binario né il runtime. Un deploy arrivato comunque a un worker del genere fallisce senza retry. - La coda ha i limiti della coda di default (
concurrency,environment_concurrency,global_concurrency) e compare con tipoplugininmanent queuese nella pagina Piattaforma. dbos.pluginsnon è ancora supportato insieme adbos.region_routing.
Code instradate#
Alcuni deploy hanno bisogno di un worker che gli altri non richiedono: una GPU, un segmento di rete raggiunto solo da alcune macchine, le credenziali di un team. Instradateli su una coda dedicata per tenant/applicazione, artefatto e ambiente, e fatela servire solo a un gruppo di worker:
queues:
- name: gpu
match:
applications: ["ml/*"] # qualificate con il tenant, come nei permessi
artifacts: [trainer, scorer]
environments: [prod]
concurrency: 2 # limite globale della coda (default: dbos.global_concurrency)
dbos:
queues: [gpu]
exclusive: true # facoltativo: solo gpu, non la coda di default né quelle dei tenant
- Le voci sono configurazione degli operatori: un descrittore non può scegliere la propria coda. All'accodamento vince la prima voce il cui
matchcorrisponde;matchrichiede almeno un pattern, e le liste vuote corrispondono a tutto. La coda instradata prevale su quella di un tenant, la coda di un plugin prevale su entrambe, perché solo i suoi worker hanno il runtime del plugin. - Solo i worker che elencano la coda in
dbos.queuesla registrano e la prelevano, oltre alle proprie; condbos.exclusiveprelevano solo le code didbos.queuesedbos.plugins(e le approvazioni, che non occupano slot di deploy). Come per le code dei plugin, appenaqueuesè impostato ogni worker preleva solo dalle code che conosce. - La coda ha
concurrencyedenvironment_concurrencydella coda di default; la suaconcurrencyè il limite globale, e limita anche i deploy della coda su ogni worker. Compare con tiporoutedinmanent queuese nella pagina Piattaforma, anche vuota: una coda instradata senza worker lascia i suoi deploy in coda. - L'instradamento si decide all'accodamento e resta nella richiesta: tick delle schedule, approvazioni, attese delle dipendenze e recovery mantengono la coda anche se
queuescambia in seguito. Un nome di coda presente anche nel registro dei tenant ferma i worker all'avvio. queuesnon è ancora supportato insieme adbos.region_routing.
Concorrenza#
La coda è partizionata per <application>/<environment>.
| Opzione | Ambito | Effetto |
|---|---|---|
environment_concurrency (default 1) |
tutti i worker | Deploy contemporanei dello stesso ambiente della stessa applicazione. Con 1 i deploy di myapp/prod sono serializzati. |
concurrency |
singolo worker | Deploy in esecuzione su quel worker. |
global_concurrency |
tutti i worker | Deploy in esecuzione in totale (0 = nessun limite). |
Deploy immediati, pianificati, ricorrenti e dry-run passano tutti dalla stessa coda e rispettano gli stessi limiti.
Recovery#
Cosa succede se un worker si ferma durante un deploy:
- il deploy resta in stato
PENDING, associato all'executor_iddel worker; - al riavvio, il worker lo riprende: gli step già completati non vengono rieseguiti, quello interrotto riparte;
- se il deploy riprende su un worker diverso, perché la macchina è stata sostituita mantenendo l'ID, e la directory di lavoro non contiene l'artefatto, lo step
deploylo riscarica e ne verifica lo sha256.
manent workers mostra stato, ultimo heartbeat e workflow in esecuzione di ogni worker; manent activity quale worker sta eseguendo ogni deploy.
Per questo gli step e i plugin devono essere idempotenti. Il plugin remote-copy, ad esempio, scrive su un file .part e lo rinomina in modo atomico: riscrivere lo stesso artefatto produce lo stesso risultato.
Arresto#
worker e serve si arrestano con SIGINT o SIGTERM. Il worker smette di prelevare deploy e attende quelli in corso fino a dbos.drain_timeout (vedi drain). Quelli non conclusi entro questo tempo restano in sospeso e vengono ripresi al riavvio. I processi dei plugin esterni vengono arrestati dopo DBOS.
Avvio contemporaneo#
Su un Postgres vuoto, più worker avviati insieme tentano di creare lo schema DBOS nello stesso momento. L'inizializzazione viene ritentata automaticamente con un'attesa crescente, quindi non serve avviare i worker uno alla volta. Anche l'API (api) e admin creano lo schema se lo trovano assente, quindi possono partire prima dei worker.