1use crate::{diagnostics::Observation, ImModule};
3use std::sync::Arc;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum SyncStage {
7 Recovery,
8 InventoryPage,
9}
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum SyncResult {
12 Started,
13 Success,
14 Failed,
15 Cancelled,
16}
17impl SyncResult {
18 pub const fn as_str(self) -> &'static str {
20 match self {
21 Self::Started => "started",
22 Self::Success => "success",
23 Self::Failed => "failed",
24 Self::Cancelled => "cancelled",
25 }
26 }
27}
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct SyncRecord {
31 pub stage: SyncStage,
32 pub result: SyncResult,
33 pub started_at_ms: u64,
34 pub completed_at_ms: u64,
35 pub total_pages: Option<u64>,
36}
37pub trait SyncObserver: Send + Sync {
38 fn record(&self, record: SyncRecord);
40}
41impl<F: Fn(SyncRecord) + Send + Sync> SyncObserver for F {
42 fn record(&self, record: SyncRecord) {
44 self(record);
45 }
46}
47#[derive(Default)]
48pub(crate) struct Timing {
49 observer: Option<Arc<dyn SyncObserver>>,
50 epoch: u64,
51 corr_floor: u64,
52 pending_operations: u64,
53 pending_reported_at_ms: Option<u64>,
54 started: Option<u64>,
55 session: String,
56 page_started: Option<u64>,
57 pub page_index: u64,
58 pub total_pages: Option<u64>,
59 pub inventory_committed: bool,
60}
61
62pub(crate) const RECOVERY_BARRIER_STARTUP_PROJECTION: u64 = 1 << 0;
65pub(crate) const RECOVERY_BARRIER_INVENTORY_COMMIT: u64 = 1 << 1;
66pub(crate) const RECOVERY_BARRIER_INCREMENT_PULL: u64 = 1 << 2;
67pub(crate) const RECOVERY_BARRIER_BATCH_PERSIST: u64 = 1 << 3;
68pub(crate) const RECOVERY_BARRIER_BATCH_PENDING: u64 = 1 << 4;
69pub(crate) const RECOVERY_BARRIER_SCHEDULER: u64 = 1 << 5;
70pub(crate) const RECOVERY_BARRIER_PENDING_COMMITS: u64 = 1 << 6;
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73struct RecoverySnapshot {
74 pending_operations: u64,
75 barrier_mask: u64,
76 scheduler_inflight: u64,
77 scheduler_pending: u64,
78 batch_persist_inflight: u64,
79}
80
81impl ImModule {
82 pub fn with_sync_observer(mut self, observer: Arc<dyn SyncObserver>) -> Self {
84 self.sync_timing.observer = Some(observer);
85 self
86 }
87 pub(crate) fn observe_sync_tick(&mut self, effects: &[helix_core::Effect], corr_floor: u64) {
89 let epoch = self.state.recovery_session.session_epoch;
90 let mut recovery_started = false;
91 if epoch != 0 && epoch != self.sync_timing.epoch {
92 self.finish_sync_observation(SyncResult::Cancelled);
93 self.sync_timing.epoch = epoch;
94 self.sync_timing.corr_floor = corr_floor;
95 self.sync_timing.pending_operations = 0;
96 self.sync_timing.pending_reported_at_ms = None;
97 self.sync_timing.started = Some(self.diagnostics.now_ms);
98 self.sync_timing.session = self.state.connection_id.clone().unwrap_or_default();
99 self.sync_timing.page_index = 0;
100 self.sync_timing.total_pages = None;
101 self.sync_timing.inventory_committed = false;
102 recovery_started = true;
103 }
104 if self.sync_timing.started.is_none() {
105 return;
106 }
107 for effect in effects {
109 let corr = match effect {
110 helix_core::Effect::Http { corr, .. }
111 | helix_core::Effect::Persist { corr, .. }
112 | helix_core::Effect::PersistAtomic { corr, .. } => *corr,
113 _ => continue,
114 };
115 if corr.raw() >= self.sync_timing.corr_floor
116 && self
117 .state
118 .corr_map
119 .get(&corr)
120 .is_some_and(is_recovery_operation)
121 {
122 self.sync_timing.pending_operations += 1;
123 }
124 }
125 if recovery_started {
127 let now = self.diagnostics.now_ms;
128 self.emit_sync_observation(SyncStage::Recovery, SyncResult::Started, now);
129 self.sync_timing.pending_reported_at_ms = Some(now);
130 }
131 if matches!(
132 self.state.recovery_session.phase,
133 crate::sync_session::RecoveryPhase::Failed
134 | crate::sync_session::RecoveryPhase::Blocked
135 ) {
136 self.finish_sync_observation(SyncResult::Failed);
137 } else if self.state.startup_channel_projection_ready
138 && self.sync_timing.pending_operations == 0
139 && self.sync_timing.inventory_committed
140 && self.state.increment_pull.is_none()
141 && self.state.channel_sync_persist_inflight == 0
142 && !self.state.channel_sync_batch_pending
143 && self.state.sync_scheduler.is_idle()
144 && !self.state.recovery_session.has_pending_commits()
145 {
146 self.finish_sync_observation(SyncResult::Success);
147 } else {
148 self.emit_recovery_pending_if_due();
149 }
150 }
151 pub(crate) fn observe_sync_reply(&mut self, tick: &helix_core::Tick) -> bool {
153 let helix_core::Tick::PortReply { corr, outcome } = tick else {
154 return false;
155 };
156 if self.sync_timing.started.is_none() || corr.raw() < self.sync_timing.corr_floor {
157 return false;
158 }
159 let Some(context) = self.state.corr_map.get(corr) else {
160 return false;
161 };
162 if !is_recovery_operation(context) {
163 return false;
164 }
165 self.sync_timing.pending_operations = self.sync_timing.pending_operations.saturating_sub(1);
166 if matches!(outcome, helix_core::tick::PortOutcome::Err(_)) {
167 self.finish_sync_observation(SyncResult::Failed);
168 } else if matches!(
169 context,
170 crate::state::CorrelationContext::IncrementBatchPersist { .. }
171 ) {
172 self.sync_timing.inventory_committed = true;
173 }
174 true
175 }
176 pub(crate) fn start_page_observation(&mut self) {
178 if self.sync_timing.started.is_none() {
179 return;
180 }
181 self.sync_timing.page_index += 1;
182 self.sync_timing.page_started = Some(self.diagnostics.now_ms);
183 self.emit_sync_observation(
184 SyncStage::InventoryPage,
185 SyncResult::Started,
186 self.diagnostics.now_ms,
187 );
188 }
189 pub(crate) fn finish_page_observation(&mut self, result: SyncResult) {
191 if let Some(started) = self.sync_timing.page_started.take() {
192 self.emit_sync_observation(SyncStage::InventoryPage, result, started);
193 }
194 }
195 pub(crate) fn finish_sync_observation(&mut self, result: SyncResult) {
197 self.finish_page_observation(result);
198 if let Some(started) = self.sync_timing.started.take() {
199 self.emit_sync_observation(SyncStage::Recovery, result, started);
200 self.sync_timing.pending_reported_at_ms = None;
201 }
202 }
203
204 fn emit_recovery_pending_if_due(&mut self) {
206 let now = self.diagnostics.now_ms;
207 if self
208 .sync_timing
209 .pending_reported_at_ms
210 .is_some_and(|last| now.saturating_sub(last) < 1_000)
211 {
212 return;
213 }
214 self.sync_timing.pending_reported_at_ms = Some(now);
215 let snapshot = self.recovery_snapshot();
216 self.diagnose(Observation {
217 event: "sync_recovery_pending",
218 stage: "client",
219 result: "pending",
220 sync_session_id: &self.sync_timing.session,
221 pending_operations: Some(snapshot.pending_operations),
222 barrier_mask: Some(snapshot.barrier_mask),
223 scheduler_inflight: Some(snapshot.scheduler_inflight),
224 scheduler_pending: Some(snapshot.scheduler_pending),
225 batch_persist_inflight: Some(snapshot.batch_persist_inflight),
226 ..Default::default()
227 });
228 }
229
230 fn recovery_snapshot(&self) -> RecoverySnapshot {
232 RecoverySnapshot {
233 pending_operations: self.sync_timing.pending_operations,
234 barrier_mask: self.recovery_barrier_mask(),
235 scheduler_inflight: self.state.sync_scheduler.inflight() as u64,
236 scheduler_pending: self.state.sync_scheduler.pending_len() as u64,
237 batch_persist_inflight: self.state.channel_sync_persist_inflight as u64,
238 }
239 }
240
241 fn recovery_barrier_mask(&self) -> u64 {
243 let mut mask = 0;
244 if !self.state.startup_channel_projection_ready {
245 mask |= RECOVERY_BARRIER_STARTUP_PROJECTION;
246 }
247 if !self.sync_timing.inventory_committed {
248 mask |= RECOVERY_BARRIER_INVENTORY_COMMIT;
249 }
250 if self.state.increment_pull.is_some() {
251 mask |= RECOVERY_BARRIER_INCREMENT_PULL;
252 }
253 if self.state.channel_sync_persist_inflight != 0 {
254 mask |= RECOVERY_BARRIER_BATCH_PERSIST;
255 }
256 if self.state.channel_sync_batch_pending {
257 mask |= RECOVERY_BARRIER_BATCH_PENDING;
258 }
259 if !self.state.sync_scheduler.is_idle() {
260 mask |= RECOVERY_BARRIER_SCHEDULER;
261 }
262 if self.state.recovery_session.has_pending_commits() {
263 mask |= RECOVERY_BARRIER_PENDING_COMMITS;
264 }
265 mask
266 }
267 fn emit_sync_observation(&self, stage: SyncStage, result: SyncResult, started: u64) {
269 let now = self.diagnostics.now_ms;
270 let snapshot = (stage == SyncStage::Recovery).then(|| self.recovery_snapshot());
271 let record = SyncRecord {
272 stage,
273 result,
274 started_at_ms: started,
275 completed_at_ms: now,
276 total_pages: self.sync_timing.total_pages,
277 };
278 if let Some(observer) = &self.sync_timing.observer {
279 observer.record(record);
280 }
281 self.diagnose(Observation {
282 event: match (stage, result) {
283 (SyncStage::Recovery, SyncResult::Started) => "sync_recovery_started",
284 (SyncStage::Recovery, _) => "sync_recovery_terminal",
285 (SyncStage::InventoryPage, SyncResult::Started) => "sync_inventory_page_started",
286 (SyncStage::InventoryPage, _) => "sync_inventory_page_terminal",
287 },
288 stage: "client",
289 result: result.as_str(),
290 sync_session_id: &self.sync_timing.session,
291 page_index: (stage == SyncStage::InventoryPage).then_some(self.sync_timing.page_index),
292 total_pages: self.sync_timing.total_pages,
293 started_at_ms: Some(started),
294 completed_at_ms: (result != SyncResult::Started).then_some(now),
295 pending_operations: snapshot.map(|value| value.pending_operations),
296 barrier_mask: snapshot.map(|value| value.barrier_mask),
297 scheduler_inflight: snapshot.map(|value| value.scheduler_inflight),
298 scheduler_pending: snapshot.map(|value| value.scheduler_pending),
299 batch_persist_inflight: snapshot.map(|value| value.batch_persist_inflight),
300 elapsed: now.saturating_sub(started) as f64 / 1000.0,
301 ..Default::default()
302 });
303 }
304}
305
306fn is_recovery_operation(context: &crate::state::CorrelationContext) -> bool {
308 use crate::state::CorrelationContext::*;
309 matches!(
310 context,
311 ScanCursors
312 | ScanChannelProjections
313 | IncrementMessageTimestampScan { .. }
314 | IncrementPullHttp
315 | IncrementPullPersist
316 | IncrementBatchPersist { .. }
317 | SyncPull { .. }
318 | ChannelPersist { .. }
319 | ChannelTerminalPersist { .. }
320 | MemberProjectionPersist { .. }
321 | TooLongReload { .. }
322 )
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::state::CorrelationContext;
329 use helix_core::{Correlation, Effect, EffectSink, Tick};
330 use std::collections::BTreeMap;
331 use std::sync::{Arc, Mutex};
332 use tracing::field::{Field, Visit};
333 use tracing::span::{Attributes, Record};
334 use tracing::subscriber::Interest;
335 use tracing::{Event, Id, Metadata, Subscriber};
336
337 #[derive(Clone, Debug)]
338 struct CapturedEvent {
339 fields: BTreeMap<String, String>,
340 }
341
342 #[derive(Clone, Default)]
343 struct Recorder {
344 events: Arc<Mutex<Vec<CapturedEvent>>>,
345 }
346
347 struct FieldRecorder<'a> {
348 fields: &'a mut BTreeMap<String, String>,
349 }
350
351 impl Visit for FieldRecorder<'_> {
352 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
353 self.fields
354 .insert(field.name().to_string(), format!("{value:?}"));
355 }
356
357 fn record_i64(&mut self, field: &Field, value: i64) {
358 self.fields
359 .insert(field.name().to_string(), value.to_string());
360 }
361
362 fn record_u64(&mut self, field: &Field, value: u64) {
363 self.fields
364 .insert(field.name().to_string(), value.to_string());
365 }
366
367 fn record_bool(&mut self, field: &Field, value: bool) {
368 self.fields
369 .insert(field.name().to_string(), value.to_string());
370 }
371
372 fn record_f64(&mut self, field: &Field, value: f64) {
373 self.fields
374 .insert(field.name().to_string(), value.to_string());
375 }
376
377 fn record_str(&mut self, field: &Field, value: &str) {
378 self.fields
379 .insert(field.name().to_string(), value.to_string());
380 }
381 }
382
383 impl Subscriber for Recorder {
384 fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest {
385 if metadata.target() == "helix::diagnostics" {
386 Interest::always()
387 } else {
388 Interest::never()
389 }
390 }
391
392 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
393 metadata.target() == "helix::diagnostics"
394 }
395
396 fn new_span(&self, _span: &Attributes<'_>) -> Id {
397 Id::from_u64(1)
398 }
399
400 fn record(&self, _span: &Id, _values: &Record<'_>) {}
401
402 fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
403
404 fn event(&self, event: &Event<'_>) {
405 if event.metadata().target() != "helix::diagnostics" {
406 return;
407 }
408 let mut fields = BTreeMap::new();
409 event.record(&mut FieldRecorder {
410 fields: &mut fields,
411 });
412 self.events
413 .lock()
414 .expect("diagnostic recorder lock")
415 .push(CapturedEvent { fields });
416 }
417
418 fn enter(&self, _span: &Id) {}
419
420 fn exit(&self, _span: &Id) {}
421 }
422
423 fn captured(recorder: &Recorder, event: &str) -> Vec<CapturedEvent> {
424 recorder
425 .events
426 .lock()
427 .expect("diagnostic recorder lock")
428 .iter()
429 .filter(|record| record.fields.get("event").map(String::as_str) == Some(event))
430 .cloned()
431 .collect()
432 }
433
434 #[test]
436 fn continuation_persist_remains_a_terminal_barrier() {
437 let mut module = ImModule::new(Default::default());
438 module.state.recovery_session.begin("actor");
439 module.observe_sync_tick(&[], 10);
440 module.sync_timing.inventory_committed = true;
441 module.state.startup_channel_projection_ready = true;
442 module.state.channel_sync_batch_pending = false;
443 module.state.recovery_session.mark_completion_published();
444 let corr = Correlation::from_raw(11);
445 module.state.corr_map.insert(
446 corr,
447 CorrelationContext::IncrementBatchPersist {
448 projections: vec![],
449 batch_id: None,
450 },
451 );
452 module.observe_sync_tick(&[Effect::PersistAtomic { corr, ops: vec![] }], 12);
453 assert!(module.sync_timing.started.is_some());
454 module.observe_sync_reply(&Tick::PortReply {
455 corr,
456 outcome: helix_core::tick::PortOutcome::Ok(helix_core::tick::ReplyBytes(
457 bytes::Bytes::new(),
458 )),
459 });
460 module.state.corr_map.remove(&corr);
461 module.observe_sync_tick(&[], 12);
462 assert!(module.sync_timing.started.is_none());
463 }
464
465 #[test]
467 fn old_inventory_receipt_does_not_complete_new_epoch() {
468 let mut module = ImModule::new(Default::default());
469 module.state.recovery_session.begin("actor");
470 module.observe_sync_tick(&[], 10);
471 let old = Correlation::from_raw(5);
472 module.state.corr_map.insert(
473 old,
474 CorrelationContext::IncrementBatchPersist {
475 projections: vec![],
476 batch_id: None,
477 },
478 );
479 module.observe_sync_reply(&Tick::PortReply {
480 corr: old,
481 outcome: helix_core::tick::PortOutcome::Ok(helix_core::tick::ReplyBytes(
482 bytes::Bytes::new(),
483 )),
484 });
485 assert!(!module.sync_timing.inventory_committed);
486 assert!(module.sync_timing.started.is_some());
487 }
488
489 #[test]
490 fn recovery_pending_snapshot_is_throttled_and_carries_barriers() {
491 let recorder = Recorder::default();
492 let dispatch = tracing::Dispatch::new(recorder.clone());
493 let mut module = ImModule::new(Default::default())
494 .with_diagnostic_session("login-a".into(), "device-a".into());
495 module.state.connection_id = Some("sync-a".into());
496 module.state.recovery_session.begin("actor-a");
497
498 tracing::dispatcher::with_default(&dispatch, || {
499 module.diagnostics.now_ms = 1_000;
500 module.observe_sync_tick(&[], 10);
501 module.diagnostics.now_ms = 1_500;
502 module.observe_sync_tick(&[], 10);
503 module.diagnostics.now_ms = 2_000;
504 module.observe_sync_tick(&[], 10);
505 });
506
507 let pending = captured(&recorder, "sync_recovery_pending");
508 assert_eq!(
509 pending.len(),
510 1,
511 "pending snapshots are at most one per second"
512 );
513 let fields = &pending[0].fields;
514 assert_eq!(
515 fields.get("login_attempt_id").map(String::as_str),
516 Some("login-a")
517 );
518 assert_eq!(
519 fields.get("device_session_id").map(String::as_str),
520 Some("device-a")
521 );
522 assert_eq!(
523 fields.get("sync_session_id").map(String::as_str),
524 Some("sync-a")
525 );
526 assert_eq!(
527 fields.get("pending_operations").map(String::as_str),
528 Some("0")
529 );
530 assert_eq!(fields.get("barrier_mask").map(String::as_str), Some("19"));
531 assert_eq!(
532 fields.get("scheduler_inflight").map(String::as_str),
533 Some("0")
534 );
535 assert_eq!(
536 fields.get("scheduler_pending").map(String::as_str),
537 Some("0")
538 );
539 assert_eq!(
540 fields.get("batch_persist_inflight").map(String::as_str),
541 Some("0")
542 );
543
544 module.state.startup_channel_projection_ready = true;
545 module.sync_timing.inventory_committed = true;
546 module.state.channel_sync_batch_pending = false;
547 module.state.recovery_session.phase = crate::sync_session::RecoveryPhase::Recovered;
548 module.diagnostics.now_ms = 2_001;
549 tracing::dispatcher::with_default(&dispatch, || module.observe_sync_tick(&[], 10));
551 let terminal = captured(&recorder, "sync_recovery_terminal");
552 assert_eq!(terminal.len(), 1);
553 assert_eq!(
554 terminal[0].fields.get("result").map(String::as_str),
555 Some("success")
556 );
557 assert_eq!(
558 terminal[0].fields.get("barrier_mask").map(String::as_str),
559 Some("0")
560 );
561 assert_eq!(
562 terminal[0]
563 .fields
564 .get("pending_operations")
565 .map(String::as_str),
566 Some("0")
567 );
568 }
569
570 #[test]
571 fn recovery_barrier_mask_has_fixed_pending_bits() {
572 let channel = crate::state::ChannelId::from_str("chfixx00000000000000000001")
573 .expect("test channel id");
574 let mut module = ImModule::new(Default::default());
575 module.state.connection_id = Some("sync-a".into());
576 module.state.recovery_session.begin("actor-a");
577 module.state.sync_scheduler.enqueue(channel);
578 module
579 .state
580 .recovery_session
581 .await_commit(channel, crate::state::Seq(1));
582 module.state.channel_sync_persist_inflight = 1;
583 let mut effects = EffectSink::new();
584 module.start_increment_pull(1, vec![], &mut effects);
585
586 let all = RECOVERY_BARRIER_STARTUP_PROJECTION
587 | RECOVERY_BARRIER_INVENTORY_COMMIT
588 | RECOVERY_BARRIER_INCREMENT_PULL
589 | RECOVERY_BARRIER_BATCH_PERSIST
590 | RECOVERY_BARRIER_BATCH_PENDING
591 | RECOVERY_BARRIER_SCHEDULER
592 | RECOVERY_BARRIER_PENDING_COMMITS;
593 assert_eq!(module.recovery_barrier_mask(), all);
594
595 module.state.startup_channel_projection_ready = true;
596 module.sync_timing.inventory_committed = true;
597 module.state.increment_pull = None;
598 module.state.channel_sync_persist_inflight = 0;
599 module.state.channel_sync_batch_pending = false;
600 module.state.sync_scheduler.reset();
601 module.state.recovery_session.pending_commits.clear();
602 assert_eq!(module.recovery_barrier_mask(), 0);
603 }
604
605 #[test]
606 fn recovery_pending_resets_between_epochs_and_keeps_old_corr_out() {
607 let recorder = Recorder::default();
608 let dispatch = tracing::Dispatch::new(recorder.clone());
609 let mut module = ImModule::new(Default::default())
610 .with_diagnostic_session("login-a".into(), "device-a".into());
611 module.state.connection_id = Some("sync-a".into());
612 module.state.recovery_session.begin("actor-a");
613 let old_corr = Correlation::from_raw(11);
614 module.state.corr_map.insert(
615 old_corr,
616 CorrelationContext::IncrementBatchPersist {
617 projections: vec![],
618 batch_id: None,
619 },
620 );
621
622 tracing::dispatcher::with_default(&dispatch, || {
623 module.diagnostics.now_ms = 1_000;
624 module.observe_sync_tick(
625 &[Effect::PersistAtomic {
626 corr: old_corr,
627 ops: vec![],
628 }],
629 10,
630 );
631 module.state.connection_id = Some("sync-b".into());
632 module.state.recovery_session.begin("actor-b");
633 module.diagnostics.now_ms = 2_000;
634 module.observe_sync_tick(&[], 12);
635 });
636
637 let started = captured(&recorder, "sync_recovery_started");
638 assert_eq!(started.len(), 2);
639 assert_eq!(
640 started[0].fields.get("sync_session_id").map(String::as_str),
641 Some("sync-a")
642 );
643 assert_eq!(
644 started[0]
645 .fields
646 .get("pending_operations")
647 .map(String::as_str),
648 Some("1")
649 );
650 assert_eq!(
651 started[1].fields.get("sync_session_id").map(String::as_str),
652 Some("sync-b")
653 );
654 assert_eq!(
655 started[1]
656 .fields
657 .get("pending_operations")
658 .map(String::as_str),
659 Some("0")
660 );
661 let cancelled = captured(&recorder, "sync_recovery_terminal");
662 assert_eq!(cancelled.len(), 1);
663 assert_eq!(
664 cancelled[0].fields.get("result").map(String::as_str),
665 Some("cancelled")
666 );
667 assert_eq!(
668 cancelled[0]
669 .fields
670 .get("sync_session_id")
671 .map(String::as_str),
672 Some("sync-a")
673 );
674 }
675}