Skip to content

Commit 047f9bf

Browse files
committed
feat(sync): add producer fencing, token refresh backoff, and stale-timeout metrics
SyncClient / FlowController enhancements: - Add `fenced` flag to SyncClient; set on `AckStatus::Fenced`. The push loop halts all outbound frames when fenced and waits for reconnect (which clears the flag). - Add `producer_id` and `accepted_epoch` fields populated from the handshake ack; stamped on every outbound frame for idempotent-producer gating by Origin. - Add per-array-ack coalescing: `pending_array_ack` is now keyed by array name so the highest-HLC ack for each array is retained and multiple arrays can be acked in one push tick. - Add token refresh exponential backoff: `token_last_attempt_ms` and `token_refresh_backoff_ms` (initial 5 s, max 5 min) prevent hammering a failing token provider; `is_refresh_backoff_elapsed` gates each attempt. - Add 30-second timeout on the token provider call so a hung provider cannot block the sync task. - Add `stale_timeouts` counter to `SyncMetrics` and `FlowController::cleanup_stale_and_record` to evict timed-out in-flight entries and increment the metric in one step.
1 parent 23e0147 commit 047f9bf

6 files changed

Lines changed: 552 additions & 18 deletions

File tree

nodedb-lite/src/sync/client/delta.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ impl SyncClient {
3434
peer_id: self.peer_id,
3535
mutation_id: delta.mutation_id,
3636
device_valid_time_ms: Some(device_valid_time_ms),
37+
// producer_id, epoch, and seq are overwritten with real producer/epoch/stable-seq in push_crdt_deltas.
38+
producer_id: 0,
39+
epoch: 0,
40+
seq: 0,
3741
})
3842
.collect()
3943
}
@@ -133,12 +137,14 @@ mod tests {
133137
collection: "orders".into(),
134138
document_id: "o1".into(),
135139
delta_bytes: vec![1, 2, 3],
140+
seq: 0,
136141
},
137142
PendingDelta {
138143
mutation_id: 2,
139144
collection: "users".into(),
140145
document_id: "u1".into(),
141146
delta_bytes: vec![4, 5, 6],
147+
seq: 0,
142148
},
143149
];
144150

@@ -159,6 +165,8 @@ mod tests {
159165
mutation_id: 1,
160166
lsn: 42,
161167
clock_skew_warning_ms: None,
168+
applied_seq: 0,
169+
status: nodedb_types::sync::wire::AckStatus::Applied,
162170
})
163171
.await;
164172

@@ -202,6 +210,7 @@ mod tests {
202210
collection: "test".into(),
203211
document_id: "d1".into(),
204212
delta_bytes,
213+
seq: 0,
205214
}];
206215
let msgs = client.build_delta_pushes(&pending).await;
207216
assert_eq!(msgs[0].checksum, expected_crc);
@@ -225,18 +234,21 @@ mod tests {
225234
collection: "a".into(),
226235
document_id: "d1".into(),
227236
delta_bytes: vec![1],
237+
seq: 0,
228238
},
229239
PendingDelta {
230240
mutation_id: 2,
231241
collection: "a".into(),
232242
document_id: "d2".into(),
233243
delta_bytes: vec![2],
244+
seq: 0,
234245
},
235246
PendingDelta {
236247
mutation_id: 3,
237248
collection: "a".into(),
238249
document_id: "d3".into(),
239250
delta_bytes: vec![3],
251+
seq: 0,
240252
},
241253
];
242254

@@ -253,6 +265,8 @@ mod tests {
253265
mutation_id: 1,
254266
lsn: 10,
255267
clock_skew_warning_ms: None,
268+
applied_seq: 0,
269+
status: nodedb_types::sync::wire::AckStatus::Applied,
256270
})
257271
.await;
258272
let msgs = client.build_delta_pushes(&pending).await;

nodedb-lite/src/sync/client/handshake.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,18 @@ impl SyncClient {
4747
clock.advance(peer_id, counter);
4848
}
4949
}
50+
drop(clock);
51+
52+
self.set_producer_id(ack.producer_id).await;
53+
self.set_accepted_epoch(ack.accepted_epoch).await;
5054

5155
*self.state.lock().await = SyncState::Connected;
52-
tracing::info!(session = %ack.session_id, "sync handshake accepted");
56+
tracing::info!(
57+
session = %ack.session_id,
58+
producer_id = ack.producer_id,
59+
accepted_epoch = ack.accepted_epoch,
60+
"sync handshake accepted"
61+
);
5362
true
5463
}
5564
}
@@ -96,6 +105,8 @@ mod tests {
96105
error: None,
97106
fork_detected: false,
98107
server_wire_version: 1,
108+
producer_id: 0,
109+
accepted_epoch: 0,
99110
};
100111

101112
assert!(client.handle_handshake_ack(&ack).await);
@@ -112,6 +123,8 @@ mod tests {
112123
error: Some("invalid token".into()),
113124
fork_detected: false,
114125
server_wire_version: 1,
126+
producer_id: 0,
127+
accepted_epoch: 0,
115128
};
116129

117130
assert!(!client.handle_handshake_ack(&ack).await);

nodedb-lite/src/sync/client/receive.rs

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,27 @@ use super::state::SyncClient;
99

1010
impl SyncClient {
1111
/// Process a ShapeSnapshot from Origin.
12+
///
13+
/// Marks the shape as snapshot-loaded and re-bases the sequence tracker
14+
/// so that gap detection restarts from the snapshot LSN rather than any
15+
/// stale watermark from before the resync. Also clears the resync gate so
16+
/// a future gap on this or another shape can trigger a new request.
1217
pub async fn handle_shape_snapshot(&self, msg: &ShapeSnapshotMsg) {
1318
let mut shapes = self.shapes.lock().await;
1419
shapes.mark_snapshot_loaded(&msg.shape_id, msg.snapshot_lsn);
20+
drop(shapes);
21+
22+
// Re-base the per-shape LSN tracker at the snapshot watermark so the
23+
// gap detector does not immediately fire again after the resync.
24+
self.last_seen_lsn
25+
.lock()
26+
.await
27+
.insert(msg.shape_id.clone(), msg.snapshot_lsn);
28+
29+
// Clear the resync gate so future gaps can trigger new requests.
30+
*self.resync_requested.lock().await = false;
31+
*self.pending_resync.lock().await = None;
32+
1533
tracing::info!(
1634
shape_id = %msg.shape_id,
1735
lsn = msg.snapshot_lsn,
@@ -74,6 +92,7 @@ impl SyncClient {
7492
},
7593
from_mutation_id: last_lsn + 1,
7694
collection: String::new(),
95+
shape_id: shape_id.to_string(),
7796
});
7897
}
7998
tracker.insert(shape_id.to_string(), lsn);
@@ -97,17 +116,33 @@ impl SyncClient {
97116
self.pending_resync.lock().await.take()
98117
}
99118

100-
/// Store a pending `ArrayAck` (set by the dispatch path on successful apply).
119+
/// Merge an `ArrayAck` into the pending-ack map.
101120
///
102-
/// A newer ack overwrites an older one: sending the highest applied HLC is
103-
/// sufficient to advance Origin's GC frontier.
121+
/// Per-array, only the ack with the highest HLC is kept — `ack_hlc_bytes` is
122+
/// stored in the same 18-byte big-endian layout used by `nodedb_array::sync::Hlc`,
123+
/// so byte-wise comparison gives the correct temporal ordering. This means no
124+
/// ack is silently lost: if two acks arrive for the same array before the push
125+
/// loop drains them, the one with the higher frontier is retained, which is
126+
/// exactly what Origin needs to advance its GC cursor.
104127
pub async fn set_pending_array_ack(&self, msg: ArrayAckMsg) {
105-
*self.pending_array_ack.lock().await = Some(msg);
128+
let mut map = self.pending_array_ack.lock().await;
129+
let entry = map.entry(msg.array.clone()).or_insert_with(|| msg.clone());
130+
if msg.ack_hlc_bytes > entry.ack_hlc_bytes {
131+
*entry = msg;
132+
}
106133
}
107134

108-
/// Take the pending `ArrayAck` (consumed by the push loop).
109-
pub async fn take_pending_array_ack(&self) -> Option<ArrayAckMsg> {
110-
self.pending_array_ack.lock().await.take()
135+
/// Drain all pending `ArrayAck`s (consumed by the push loop).
136+
///
137+
/// Returns all per-array acks accumulated since the last drain. The map is
138+
/// cleared so new acks can accumulate for the next tick.
139+
pub async fn drain_pending_array_acks(&self) -> Vec<ArrayAckMsg> {
140+
let mut map = self.pending_array_ack.lock().await;
141+
if map.is_empty() {
142+
return Vec::new();
143+
}
144+
let drained: Vec<ArrayAckMsg> = map.drain().map(|(_, v)| v).collect();
145+
drained
111146
}
112147
}
113148

@@ -185,6 +220,98 @@ mod tests {
185220
assert!(client.check_sequence_gap("s1", 20).await.is_none());
186221
}
187222

223+
#[tokio::test]
224+
async fn array_ack_merge_keeps_highest_hlc() {
225+
let client = SyncClient::new(make_config(), 1);
226+
227+
// Lower HLC bytes first.
228+
let lower = ArrayAckMsg {
229+
array: "arr1".into(),
230+
replica_id: 1,
231+
ack_hlc_bytes: [0x00; 18],
232+
applied_seq: 0,
233+
status: nodedb_types::sync::wire::AckStatus::Applied,
234+
};
235+
let higher = ArrayAckMsg {
236+
array: "arr1".into(),
237+
replica_id: 1,
238+
ack_hlc_bytes: [0xFF; 18],
239+
applied_seq: 0,
240+
status: nodedb_types::sync::wire::AckStatus::Applied,
241+
};
242+
243+
client.set_pending_array_ack(lower).await;
244+
client.set_pending_array_ack(higher.clone()).await;
245+
246+
let drained = client.drain_pending_array_acks().await;
247+
assert_eq!(drained.len(), 1);
248+
assert_eq!(drained[0].ack_hlc_bytes, higher.ack_hlc_bytes);
249+
}
250+
251+
#[tokio::test]
252+
async fn array_ack_merge_keeps_higher_over_lower() {
253+
let client = SyncClient::new(make_config(), 1);
254+
255+
// Insert higher first, then lower — should still keep higher.
256+
let mut higher_bytes = [0x00u8; 18];
257+
higher_bytes[0] = 0x10;
258+
let mut lower_bytes = [0x00u8; 18];
259+
lower_bytes[0] = 0x01;
260+
261+
client
262+
.set_pending_array_ack(ArrayAckMsg {
263+
array: "arr2".into(),
264+
replica_id: 1,
265+
ack_hlc_bytes: higher_bytes,
266+
applied_seq: 0,
267+
status: nodedb_types::sync::wire::AckStatus::Applied,
268+
})
269+
.await;
270+
client
271+
.set_pending_array_ack(ArrayAckMsg {
272+
array: "arr2".into(),
273+
replica_id: 1,
274+
ack_hlc_bytes: lower_bytes,
275+
applied_seq: 0,
276+
status: nodedb_types::sync::wire::AckStatus::Applied,
277+
})
278+
.await;
279+
280+
let drained = client.drain_pending_array_acks().await;
281+
assert_eq!(drained.len(), 1);
282+
assert_eq!(drained[0].ack_hlc_bytes, higher_bytes);
283+
}
284+
285+
#[tokio::test]
286+
async fn array_ack_merge_separate_arrays_both_drained() {
287+
let client = SyncClient::new(make_config(), 1);
288+
289+
client
290+
.set_pending_array_ack(ArrayAckMsg {
291+
array: "arr_a".into(),
292+
replica_id: 1,
293+
ack_hlc_bytes: [0x01; 18],
294+
applied_seq: 0,
295+
status: nodedb_types::sync::wire::AckStatus::Applied,
296+
})
297+
.await;
298+
client
299+
.set_pending_array_ack(ArrayAckMsg {
300+
array: "arr_b".into(),
301+
replica_id: 1,
302+
ack_hlc_bytes: [0x02; 18],
303+
applied_seq: 0,
304+
status: nodedb_types::sync::wire::AckStatus::Applied,
305+
})
306+
.await;
307+
308+
let mut drained = client.drain_pending_array_acks().await;
309+
drained.sort_by(|a, b| a.array.cmp(&b.array));
310+
assert_eq!(drained.len(), 2);
311+
assert_eq!(drained[0].array, "arr_a");
312+
assert_eq!(drained[1].array, "arr_b");
313+
}
314+
188315
#[tokio::test]
189316
async fn reset_sequence_tracking_clears_state() {
190317
let client = SyncClient::new(make_config(), 1);

0 commit comments

Comments
 (0)