Skip to main content

nodedb_lite/sync/client/
state.rs

1//! `SyncClient` struct, constructors, and simple accessors.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use tokio::sync::Mutex;
7
8use nodedb_types::sync::wire::{
9    ArrayAckMsg, ArrayCatchupRequestMsg, ResyncRequestMsg, RowPushRejectMsg,
10};
11
12/// Pending array acks keyed by array name.
13///
14/// Holding one entry per array name (the highest-HLC ack seen for that array)
15/// is sufficient to advance Origin's GC frontier: Origin only needs to know the
16/// highest durable HLC per replica per array, not every intermediate ack.
17type PendingArrayAcks = std::collections::HashMap<String, ArrayAckMsg>;
18
19/// Pending array catch-up requests keyed by array name, sent in name order.
20type PendingArrayCatchups = std::collections::BTreeMap<String, ArrayCatchupRequestMsg>;
21
22use super::array_push::ArrayPushWindow;
23use super::config::{SyncConfig, SyncState};
24use crate::sync::clock::VectorClock;
25use crate::sync::compensation::{CompensationHandler, CompensationRegistry};
26use crate::sync::flow_control::{FlowControlConfig, FlowController, SyncMetrics};
27use crate::sync::shapes::ShapeManager;
28
29/// Sync client — manages the WebSocket connection to Origin.
30///
31/// The client runs as a background Tokio task. It:
32/// 1. Connects to Origin via WebSocket
33/// 2. Sends handshake with JWT + vector clock + shape subscriptions
34/// 3. Pushes accumulated CRDT deltas
35/// 4. Receives shape snapshots and incremental deltas
36/// 5. Handles rejections via CompensationRegistry
37/// 6. Auto-reconnects with exponential backoff on disconnect
38pub struct SyncClient {
39    pub(super) config: SyncConfig,
40    pub(super) state: Arc<Mutex<SyncState>>,
41    pub(super) clock: Arc<Mutex<VectorClock>>,
42    pub(super) shapes: Arc<Mutex<ShapeManager>>,
43    pub(super) compensation: Arc<CompensationRegistry>,
44    /// Session ID assigned by Origin after handshake.
45    pub(super) session_id: Arc<Mutex<Option<String>>>,
46    /// Sequence tracker: per-shape, the last LSN received from Origin.
47    pub(super) last_seen_lsn: Arc<Mutex<std::collections::HashMap<String, u64>>>,
48    /// Whether a re-sync request has been sent for this connection.
49    pub(super) resync_requested: Arc<Mutex<bool>>,
50    /// Pending re-sync request to send to Origin.
51    pub(super) pending_resync: Arc<Mutex<Option<ResyncRequestMsg>>>,
52    /// Flow controller: in-flight window, adaptive batch sizing, queue bounds.
53    pub(super) flow: Arc<Mutex<FlowController>>,
54    /// Sync metrics: atomic counters for monitoring.
55    pub(super) metrics: Arc<SyncMetrics>,
56    /// Timestamp (epoch ms) when the current JWT was set (for proactive refresh).
57    pub(super) token_set_at_ms: Arc<Mutex<u64>>,
58    /// Whether a token refresh is currently in-flight.
59    pub(super) token_refresh_pending: Arc<Mutex<bool>>,
60    /// Whether delta push is paused due to auth failure (awaiting refresh).
61    pub(super) push_paused_for_auth: Arc<Mutex<bool>>,
62    /// Epoch-ms timestamp of the last token refresh attempt (successful or not).
63    /// Used with `token_refresh_backoff_ms` to enforce a minimum retry interval.
64    pub(super) token_last_attempt_ms: Arc<Mutex<u64>>,
65    /// Current backoff delay (ms) before the next refresh attempt is allowed.
66    /// Doubles on each consecutive failure (exponential), capped at 5 minutes.
67    pub(super) token_refresh_backoff_ms: Arc<Mutex<u64>>,
68    /// Pending array acks to send on the next push-loop tick, keyed by array name.
69    ///
70    /// Set by `dispatch_frame` when an `ArrayDelta` or `ArrayDeltaBatch` is
71    /// successfully applied. Each entry holds the highest-HLC ack seen for that
72    /// array since the last drain. The push loop drains all entries and transmits
73    /// them to Origin to advance the GC frontier.
74    pub(super) pending_array_ack: Arc<Mutex<PendingArrayAcks>>,
75    /// `ArrayCatchupRequest` frames waiting for the next push tick, keyed by
76    /// array name so a later request for an array replaces an unsent one.
77    ///
78    /// Filled once per session from the delegate's catch-up round, and again
79    /// when Origin sends a `RetentionFloor` reject.
80    pub(super) pending_array_catchups: Arc<Mutex<PendingArrayCatchups>>,
81    /// Producer ID assigned by Origin in `HandshakeAckMsg`.
82    ///
83    /// Used to stamp outbound frames so Origin can route acks back to this
84    /// producer. `None` until the first successful handshake.
85    pub(super) producer_id: Arc<Mutex<Option<u64>>>,
86    /// Accepted epoch echoed by Origin in `HandshakeAckMsg`.
87    ///
88    /// Confirms Origin accepted the epoch sent in our handshake. `None` until
89    /// the first successful handshake.
90    pub(super) accepted_epoch: Arc<Mutex<Option<u64>>>,
91    /// Set to `true` when Origin returns `AckStatus::Fenced` on any frame.
92    ///
93    /// Means the producer epoch is stale and Origin has a newer epoch on record.
94    /// The sync loop must disconnect and reconnect; on reconnect the handshake
95    /// will present the persisted epoch (from storage) which Origin already
96    /// accepted. If LiteIdentity bumps epoch only on db-open (not reconnect),
97    /// the epoch stays the same across reconnects and will still be fenced.
98    /// In that case the operator must restart the db process to mint a new epoch.
99    pub(crate) fenced: Arc<AtomicBool>,
100    /// Collection names already announced (via `CollectionSchema`, opcode
101    /// `0x13`) to Origin during the current session.
102    ///
103    /// Cleared whenever `session_id` is (re)set on handshake so each new
104    /// session re-announces every collection with pending deltas, mirroring
105    /// Origin's per-session announced set in `session_handler/announce.rs`.
106    pub(super) announced_collections: Arc<Mutex<std::collections::HashSet<String>>>,
107    /// Highest `RowPushMsg.sequence` applied so far, keyed by
108    /// `(peer_id, collection)`.
109    ///
110    /// Client → server writes are protected by Origin's `sync_admit` gate
111    /// (producer/epoch/seq), but nothing symmetric exists for server →
112    /// client `RowPush` frames. Without this, a re-delivered or
113    /// out-of-order frame is applied blindly: a duplicate delete-then-push
114    /// re-delivery can resurrect a row a later write already removed, and
115    /// an out-of-order pair can leave a stale post-image as the winner.
116    /// In-memory and per-connection is the right scope: Origin's fan-out
117    /// is a live mpsc channel with no replay across reconnects, so there is
118    /// nothing durable to reconcile against after a reconnect anyway.
119    ///
120    /// Nested by peer_id then collection (rather than a flat `(u64, String)`
121    /// key) so the common case — checking the mark for a collection that is
122    /// already tracked — looks it up by `&str` without allocating; a `String`
123    /// is only allocated the first time a given collection is seen.
124    pub(super) row_push_watermark:
125        Arc<Mutex<std::collections::HashMap<u64, std::collections::HashMap<String, u64>>>>,
126    /// What each pushed mutation targets: `mutation_id → (collection, document_id)`.
127    ///
128    /// `DeltaAckMsg` and `DeltaRejectMsg` identify a delta by mutation id
129    /// alone, so this is the only way a rejection can be reported to the
130    /// application against the row it refused. Entries are removed when the
131    /// mutation is acknowledged or rejected, so the map tracks the in-flight
132    /// window rather than growing with the session.
133    pub(super) delta_targets: Arc<Mutex<std::collections::HashMap<u64, (String, String)>>>,
134    /// `RowPushReject` frames waiting for the next push tick, one per
135    /// `RowPush` this replica refused. Bounded by `ROW_PUSH_REJECT_CAP`.
136    pub(super) pending_row_push_rejects: Arc<Mutex<Vec<RowPushRejectMsg>>>,
137    /// Per-session array push state: the open delta window, schema
138    /// announces, and ops held until the next session. Reset on connect.
139    pub(super) array_push: Arc<Mutex<ArrayPushWindow>>,
140}
141
142impl SyncClient {
143    /// Create a new sync client (does not connect yet).
144    ///
145    /// The client holds no copy of the instance's identity. `lite_id`, `epoch`,
146    /// and the Loro peer id are read from the delegate at the moment each frame
147    /// is built, because all three change while a client is alive — a peer-id
148    /// rotation or a fork recovery replaces them — and a cached copy would keep
149    /// stamping outbound frames with the identity Origin just refused.
150    pub fn new(config: SyncConfig) -> Self {
151        Self::with_flow_control(config, FlowControlConfig::default())
152    }
153
154    /// Create a new sync client with custom flow control config.
155    pub fn with_flow_control(config: SyncConfig, flow_config: FlowControlConfig) -> Self {
156        Self {
157            config,
158            state: Arc::new(Mutex::new(SyncState::Disconnected)),
159            clock: Arc::new(Mutex::new(VectorClock::new())),
160            shapes: Arc::new(Mutex::new(ShapeManager::new())),
161            compensation: Arc::new(CompensationRegistry::new()),
162            session_id: Arc::new(Mutex::new(None)),
163            last_seen_lsn: Arc::new(Mutex::new(std::collections::HashMap::new())),
164            resync_requested: Arc::new(Mutex::new(false)),
165            pending_resync: Arc::new(Mutex::new(None)),
166            flow: Arc::new(Mutex::new(FlowController::new(flow_config))),
167            metrics: Arc::new(SyncMetrics::new()),
168            token_set_at_ms: Arc::new(Mutex::new(crate::runtime::now_millis())),
169            token_refresh_pending: Arc::new(Mutex::new(false)),
170            push_paused_for_auth: Arc::new(Mutex::new(false)),
171            pending_array_ack: Arc::new(Mutex::new(PendingArrayAcks::new())),
172            pending_array_catchups: Arc::new(Mutex::new(PendingArrayCatchups::new())),
173            producer_id: Arc::new(Mutex::new(None)),
174            accepted_epoch: Arc::new(Mutex::new(None)),
175            fenced: Arc::new(AtomicBool::new(false)),
176            token_last_attempt_ms: Arc::new(Mutex::new(0)),
177            token_refresh_backoff_ms: Arc::new(Mutex::new(
178                crate::sync::client::token::TOKEN_REFRESH_MIN_BACKOFF_MS,
179            )),
180            announced_collections: Arc::new(Mutex::new(std::collections::HashSet::new())),
181            row_push_watermark: Arc::new(Mutex::new(std::collections::HashMap::new())),
182            delta_targets: Arc::new(Mutex::new(std::collections::HashMap::new())),
183            pending_row_push_rejects: Arc::new(Mutex::new(Vec::new())),
184            array_push: Arc::new(Mutex::new(ArrayPushWindow::default())),
185        }
186    }
187
188    /// Current connection state.
189    pub async fn state(&self) -> SyncState {
190        *self.state.lock().await
191    }
192
193    /// Set the connection state.
194    pub async fn set_state(&self, new_state: SyncState) {
195        *self.state.lock().await = new_state;
196    }
197
198    /// Register a compensation handler.
199    pub fn set_compensation_handler(&self, handler: Arc<dyn CompensationHandler>) {
200        self.compensation.set_handler(handler);
201    }
202
203    /// Access the shape manager (for subscribing/unsubscribing).
204    pub fn shapes(&self) -> &Arc<Mutex<ShapeManager>> {
205        &self.shapes
206    }
207
208    /// Access the vector clock.
209    pub fn clock(&self) -> &Arc<Mutex<VectorClock>> {
210        &self.clock
211    }
212
213    /// Access the compensation registry.
214    pub fn compensation(&self) -> &Arc<CompensationRegistry> {
215        &self.compensation
216    }
217
218    /// Access config.
219    pub fn config(&self) -> &SyncConfig {
220        &self.config
221    }
222
223    /// Access the flow controller.
224    pub fn flow(&self) -> &Arc<Mutex<FlowController>> {
225        &self.flow
226    }
227
228    /// Access the sync metrics.
229    pub fn metrics(&self) -> &Arc<SyncMetrics> {
230        &self.metrics
231    }
232
233    /// Producer ID assigned by Origin, or 0 if the handshake has not yet completed.
234    pub async fn producer_id(&self) -> u64 {
235        self.producer_id.lock().await.unwrap_or_default()
236    }
237
238    /// Accepted epoch echoed by Origin, or 0 if the handshake has not yet completed.
239    pub async fn accepted_epoch(&self) -> u64 {
240        self.accepted_epoch.lock().await.unwrap_or_default()
241    }
242
243    /// Store the server-assigned producer ID.
244    pub(super) async fn set_producer_id(&self, id: u64) {
245        *self.producer_id.lock().await = Some(id);
246    }
247
248    /// Store the accepted epoch echoed by Origin.
249    pub(super) async fn set_accepted_epoch(&self, epoch: u64) {
250        *self.accepted_epoch.lock().await = Some(epoch);
251    }
252
253    /// Load producer state (producer_id + accepted_epoch) from previously
254    /// persisted values. Called on reconnect so the client knows its identity
255    /// before the next handshake.
256    pub async fn load_producer_state(&self, producer_id: u64, accepted_epoch: u64) {
257        *self.producer_id.lock().await = Some(producer_id);
258        *self.accepted_epoch.lock().await = Some(accepted_epoch);
259    }
260
261    /// Whether Origin fenced this producer.
262    ///
263    /// When `true`, the sync loop should disconnect and reconnect. The epoch
264    /// is only bumped on db-open (via `LiteIdentity`), so reconnecting
265    /// alone does not change the epoch. A fenced producer requires the
266    /// operator to restart the process to mint a fresh epoch.
267    pub fn is_fenced(&self) -> bool {
268        self.fenced.load(Ordering::Acquire)
269    }
270
271    /// Mark this producer as fenced by Origin.
272    ///
273    /// Also unsets the `push_paused_for_auth` flag so the disconnect path is
274    /// not confused with an auth-pause: fencing is a permanent producer-epoch
275    /// rejection, not a token issue.
276    pub fn set_fenced(&self) {
277        self.fenced.store(true, Ordering::Release);
278        tracing::error!(
279            "producer epoch fenced by Origin — this producer's epoch is stale; \
280             process restart required to mint a new epoch"
281        );
282    }
283
284    /// Clear the fenced flag. Called on reconnect so the client can attempt
285    /// re-registration; if Origin still fences it the flag is set again.
286    pub fn clear_fenced(&self) {
287        self.fenced.store(false, Ordering::Release);
288    }
289
290    /// Access the per-session set of collections already announced via
291    /// `CollectionSchema` (opcode `0x13`).
292    pub(crate) fn announced_collections(&self) -> &Arc<Mutex<std::collections::HashSet<String>>> {
293        &self.announced_collections
294    }
295
296    /// Access the per-session array push state.
297    pub(crate) fn array_push(&self) -> &Arc<Mutex<ArrayPushWindow>> {
298        &self.array_push
299    }
300
301    /// Returns true when this row push should be applied.
302    ///
303    /// Every client → server engine path is protected by Origin's
304    /// `sync_admit` gate (producer/epoch/seq); there is no equivalent for
305    /// server → client `RowPush` frames. Without this gate, a re-delivered
306    /// frame is applied unconditionally: a duplicate delivery of a stale
307    /// post-image can resurrect a row a later, un-replayed write already
308    /// deleted, and an out-of-order pair can leave the older post-image as
309    /// the final state.
310    ///
311    /// `sequence == 0` is the unsequenced sentinel — it mirrors how Origin
312    /// treats `producer_id == 0` as unidentified in `sync_gate.rs`, marking
313    /// DDL-managed system rows (retention policies, alerts) that were never
314    /// assigned a per-collection sequence. Such a frame carries no ordering
315    /// information to dedup against, so it is always applied and never
316    /// allowed to move the watermark (doing so would let a later
317    /// legitimately-sequenced frame for the same collection be skipped as
318    /// "stale").
319    pub async fn admit_row_push(&self, peer_id: u64, collection: &str, sequence: u64) -> bool {
320        if sequence == 0 {
321            return true;
322        }
323
324        let mut watermark = self.row_push_watermark.lock().await;
325        let per_collection = watermark.entry(peer_id).or_default();
326        let mark = per_collection.get(collection).copied().unwrap_or(0);
327
328        if sequence <= mark {
329            tracing::debug!(
330                peer_id,
331                collection,
332                sequence,
333                watermark = mark,
334                "RowPush: sequence at or below high-water mark, skipping duplicate/stale frame"
335            );
336            return false;
337        }
338
339        match per_collection.get_mut(collection) {
340            Some(mark) => *mark = sequence,
341            None => {
342                per_collection.insert(collection.to_string(), sequence);
343            }
344        }
345        true
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn make_config() -> SyncConfig {
354        SyncConfig::new("wss://localhost:9090/sync", "test.jwt.token")
355    }
356
357    #[tokio::test]
358    async fn initial_state_is_disconnected() {
359        let client = SyncClient::new(make_config());
360        assert_eq!(client.state().await, SyncState::Disconnected);
361    }
362
363    /// A re-delivered frame at the same sequence must be admitted once, not
364    /// twice — otherwise Origin re-sending an already-applied RowPush (e.g.
365    /// after a reconnect) would re-apply a stale post-image on top of newer
366    /// local state.
367    #[tokio::test]
368    async fn duplicate_sequence_is_admitted_once() {
369        let client = SyncClient::new(make_config());
370
371        assert!(client.admit_row_push(1, "orders", 5).await);
372        assert!(
373            !client.admit_row_push(1, "orders", 5).await,
374            "duplicate sequence must be admitted exactly once"
375        );
376    }
377
378    /// A lower sequence arriving after a higher one is a stale/out-of-order
379    /// re-delivery and must be refused, or it would leave an older post-image
380    /// as the final applied state.
381    #[tokio::test]
382    async fn lower_sequence_after_higher_is_refused() {
383        let client = SyncClient::new(make_config());
384
385        assert!(client.admit_row_push(1, "orders", 10).await);
386        assert!(!client.admit_row_push(1, "orders", 3).await);
387    }
388
389    /// The watermark is keyed per `(peer_id, collection)`, not globally — two
390    /// different collections reusing the same sequence number (each has its
391    /// own monotonic counter on Origin) must both be admitted.
392    #[tokio::test]
393    async fn same_sequence_different_collections_both_admitted() {
394        let client = SyncClient::new(make_config());
395
396        assert!(client.admit_row_push(1, "orders", 1).await);
397        assert!(
398            client.admit_row_push(1, "invoices", 1).await,
399            "a shared sequence number across distinct collections must not collide in the gate"
400        );
401    }
402
403    /// `sequence == 0` is the unsequenced sentinel for DDL-managed system rows.
404    /// It carries no ordering information, so it must always be admitted and
405    /// must never move the watermark for its collection.
406    #[tokio::test]
407    async fn unsequenced_frames_always_admit_and_never_gate() {
408        let client = SyncClient::new(make_config());
409
410        assert!(client.admit_row_push(1, "system_alerts", 0).await);
411        assert!(client.admit_row_push(1, "system_alerts", 0).await);
412        assert!(
413            client.admit_row_push(1, "system_alerts", 1).await,
414            "sequence-0 frames must always admit and must not block a later sequenced frame"
415        );
416    }
417}