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}