Vai al contenuto

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:

manent.yml (tutti i processi)
plugins:
  ansible:
    external: {command: /usr/local/bin/manent-plugin-ansible, sha256: "<sha256>"}
    artifact_types: [ansible]
    queue: ansible
manent.yml (solo i worker del gruppo, immagine server-ansible)
dbos:
  plugins: [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.plugins registrano 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 tipo plugin in manent queues e nella pagina Piattaforma.
  • dbos.plugins non è ancora supportato insieme a dbos.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:

manent.yml (tutti i processi)
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)
manent.yml (solo i worker del gruppo)
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 match corrisponde; match richiede 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.queues la registrano e la prelevano, oltre alle proprie; con dbos.exclusive prelevano solo le code di dbos.queues e dbos.plugins (e le approvazioni, che non occupano slot di deploy). Come per le code dei plugin, appena queues è impostato ogni worker preleva solo dalle code che conosce.
  • La coda ha concurrency ed environment_concurrency della coda di default; la sua concurrency è il limite globale, e limita anche i deploy della coda su ogni worker. Compare con tipo routed in manent queues e 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 queues cambia in seguito. Un nome di coda presente anche nel registro dei tenant ferma i worker all'avvio.
  • queues non è ancora supportato insieme a dbos.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:

  1. il deploy resta in stato PENDING, associato all'executor_id del worker;
  2. al riavvio, il worker lo riprende: gli step già completati non vengono rieseguiti, quello interrotto riparte;
  3. 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 deploy lo 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.