Protocollo di sincronizzazione
Openbeehive è offline-first. Ogni lettura e scrittura avviene contro un database SQLite-WASM locale sul dispositivo, e un processo in background riconcilia quello stato locale con il server. Questa pagina documenta il protocollo di trasmissione che fa funzionare la riconciliazione: il servizio Connect-RPC, i suoi tre metodi, e le regole che entrambe le parti applicano quando uniscono le modifiche.
Se non hai ancora letto la panoramica del modello di sincronizzazione, parti da lì. Questa pagina presuppone che tu sappia già che Openbeehive usa gli Hybrid Logical Clocks (HLC), il last-writer-wins (LWW) per campo per gli scalari, gli OR-Set (add-wins) per i campi elenco, e gli eventi append-only che non entrano mai in conflitto.
Il servizio
La sincronizzazione è esposta come un servizio Connect-RPC, così ogni metodo è raggiungibile sia come gRPC sia come semplice HTTP/JSON. Ci sono tre metodi:
| Metodo | Direzione | Scopo |
|---|---|---|
Pull | client ← server | Recupera le modifiche che il client non ha ancora visto |
Push | client → server | Invia le modifiche locali al server |
Subscribe | server → client (stream) | "Spinta" opzionale quasi in tempo reale quando arrivano nuove modifiche |
Un ciclo tipico del client: esegue il Push della sua coda di uscita locale, poi
il Pull di tutto ciò che è nuovo, quindi resta inattivo finché Subscribe non
lo sollecita (o scatta un timer) e ripete.
I cursori contro l'HLC
L'idea più importante in questo protocollo è che il cursore di sincronizzazione non è l'HLC.
L'HLC è una marca temporale logica allegata a ogni scrittura di campo. Decide quale valore vince durante un'unione — risponde alla domanda "questa modifica è più recente di quella che ho già?". Gli HLC provengono da molti dispositivi, possono spostarsi leggermente fuori ordine rispetto all'orologio reale, e non sono globalmente monotoni nell'ordine di arrivo.
Il cursore è una sequenza di ricezione assegnata dal server — un singolo numero
intero strettamente crescente (seq) che il server marca su ogni modifica quando
viene accettata in modo durevole. Risponde a una domanda completamente diversa:
"cosa ho già consegnato a questo client?".
Usare la sequenza di ricezione come cursore ci dà due garanzie che l'HLC non può offrire:
- Ordine totale della consegna. Poiché
seqviene assegnato nell'ordine in cui il server accetta le scritture, un client può chiedere "tutto dopo seq N" ed essere certo di non perdere nulla, anche se quelle modifiche portano HLC fuori ordine. - Sicurezza in caso di replay. Un client può persistere il proprio cursore e riprendere esattamente da dove si era interrotto dopo essere andato offline, essersi bloccato o essere stato reinstallato.
Non usare mai un HLC come cursore di impaginazione. Due dispositivi possono
legittimamente produrre la stessa regione di HLC mentre sono offline, e gli HLC
non vengono assegnati nell'ordine di arrivo — impaginare per HLC salterebbe o
duplicherebbe le modifiche. Impagina per seq, unisci per HLC.
Pull
Pull restituisce le modifiche che il client non ha visto, in ordine di seq,
più il cursore da usare la volta successiva.
Pull(PullRequest { since_cursor: int64, limit: int32 })
-> PullResponse { changes: Change[], next_cursor: int64, has_more: bool }
since_cursorè l'ultimo cursore che il client ha applicato con successo. Invia0per una prima sincronizzazione completa.changesviene restituito ordinato perseqcrescente, limitato agli ambiti che il chiamante può leggere (vedi Replica parziale).next_cursorè ilseqpiù alto incluso in questa pagina. Persistilo solo dopo che l'intera pagina è stata applicata localmente.has_moreètruequando il risultato è stato troncato dalimit; il client dovrebbe eseguire immediatamente di nuovo ilPullcon il nuovonext_cursor.
Un singolo Change porta abbastanza informazioni per essere unito in modo
indipendente:
{
"entity": "hive",
"entity_id": "01HZX...",
"scope_id": "apiary-01HZ...",
"kind": "field",
"field": "name",
"value": "Hive 3 (north row)",
"hlc": "2026-06-19T09:14:02.117Z-0003-nodeA",
"seq": 48213
}
kind distingue le tre strategie di unione: field (LWW scalare),
set_add / set_remove (appartenenza a un OR-Set), ed event (append-only).
Push
Push invia un lotto di modifiche locali al server. Il server applica ciascuna
con le stesse regole di unione che usa il client, assegna un nuovo seq a ogni
modifica accettata, e riferisce indietro.
Push(PushRequest { changes: Change[] })
-> PushResponse { server_cursor: int64, conflicts: Conflict[] }
- Il server verifica che il chiamante possa scrivere lo
scope_iddi ogni modifica. - Per ogni modifica scalare
fieldapplica il LWW per campo: il valore in arrivo vince solo se il suo HLC è maggiore dell'HLC attualmente memorizzato per quel campo. - Le operazioni sugli insiemi vengono applicate come add/remove di OR-Set; le aggiunte vincono sulle rimozioni concorrenti.
- Le modifiche
eventvengono aggiunte incondizionatamente — non entrano mai in conflitto. - A ogni modifica accettata viene assegnato un nuovo
seqstrettamente crescente. server_cursorè ilseqpiù alto assegnato in questo lotto, così il client può avanzare rapidamente senza un ulteriore giro diPullper le proprie scritture.
Conflitti
conflicts non è un elenco di errori — l'unione è sempre deterministica e
ha sempre successo. È un elenco informativo dei campi in cui il server deteneva
già un valore con un HLC più alto, quindi il valore inviato dal client non è
stato adottato.
{
"entity_id": "01HZX...",
"field": "queen_status",
"rejected_hlc": "2026-06-19T09:13:55.000Z-0001-nodeB",
"winning_hlc": "2026-06-19T09:14:10.421Z-0007-nodeA"
}
Il client dovrebbe trattare un conflitto come un segnale per aggiornare quel campo
dal Pull successivo, dove riceverà il valore vincente. Non è necessario alcun
nuovo tentativo.
Poiché il LWW è deterministico e ordinato per HLC, Push è idempotente:
reinviare una modifica il cui HLC ha già perso (o ha già vinto) lascia invariato
lo stato del server. I client possono ritentare con sicurezza un Push dopo una
connessione interrotta.
Subscribe
Subscribe è un canale opzionale in streaming dal server usato puramente come
segnale di risveglio. Non trasporta dati.
Subscribe(SubscribeRequest { scopes: string[] })
-> stream Poke { scope_id: string, server_cursor: int64 }
Quando una scrittura arriva in uno degli ambiti leggibili dal client, il server
emette un Poke. Il client risponde chiamando Pull(since_cursor) come al
solito. Mantenere i dati effettivi sul Pull significa che lo stream può essere
soggetto a perdite senza compromettere la correttezza — un poke mancato significa
soltanto che il Pull successivo guidato dal timer recupera.
Subscribe è un'ottimizzazione della latenza, non un requisito. Un client che si
limita a interrogare il Pull con un timer è del tutto corretto; è semplicemente
meno tempestivo.
Replica parziale per ambito
In Openbeehive la condivisione avviene a livello di apiario tramite gli ambiti. Un utente replica solo i dati all'interno degli ambiti che può leggere, non l'intero database.
Questo viene applicato su Pull e Push:
Pullfiltrachangesagli ambiti leggibili dal chiamante prima di impaginare perseq. Il cursore avanza quindi su una vista per ciascun chiamante della sequenza globale — due utenti che condividono un apiario vedranno le modifiche di quell'apiario allo stessoseq, mentre ciascuno vede anche i propri ambiti privati.Pushrifiuta le scritture verso ambiti che il chiamante non può scrivere.
Poiché il cursore è la sequenza di ricezione globale, un client può vedere dei
vuoti nei valori seq che riceve (le modifiche negli ambiti che non può leggere
vengono saltate). I vuoti sono attesi e innocui — al client serve sempre e solo il
prossimo cursore per chiedere altro.
Logica di unione rispecchiata
Le funzioni di unione — confronto HLC, LWW per campo, risoluzione OR-Set,
aggiunta di eventi — sono identiche sul client e sul server. La stessa logica
che la PWA SvelteKit esegue quando applica un Pull è la logica che il backend Go
esegue quando applica un Push.
Questo rispecchiamento è ciò che rende il sistema genuinamente privo di conflitti anziché meramente risolutore di conflitti:
- Una modifica produce lo stesso risultato unito indipendentemente da dove viene applicata o in quale ordine arriva, così client e server convergono senza negoziazione.
- Il server non è un arbitro privilegiato che può ignorare lo stato del
dispositivo; applica le stesse regole deterministiche, poi assegna un
seqper l'ordinamento. - I nuovi tipi di entità hanno bisogno solo che le loro regole di unione siano definite una volta, nella semantica condivisa, ed entrambe le parti le rispettano.
Per i dettagli sottostanti dell'orologio e delle strutture dati, vedi la panoramica dell'architettura e il modello dei dati. Per come gli eventi si inseriscono nel percorso append-only, vedi storia ed eventi.