Skip to main content

helix_im/
sync_observation.rs

1//! 同步时序的只读旁路:单轮和单页 O(1) 状态,指标出口由宿主提供。
2use 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    /// 固定枚举同时约束日志和低基数标签。
19    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/// 仅传标量给宿主,实体与关联 ID 不进入指标端口。
29#[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    /// 实现必须非阻塞;丢弃观测不得影响业务结果。
39    fn record(&self, record: SyncRecord);
40}
41impl<F: Fn(SyncRecord) + Send + Sync> SyncObserver for F {
42    /// 装配层可用闭包转接标量,无需让通用driver反向依赖IM。
43    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
62/// `barrier_mask` 是诊断快照,不参与完成判定;bit=1 表示该屏障仍未闭合。
63/// `pending_operations` 单独保留,避免把异步 Effect 数量塞进业务 `count`。
64pub(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    /// 注入独立指标出口,诊断日志关闭时仍记录同步时间。
83    pub fn with_sync_observer(mut self, observer: Arc<dyn SyncObserver>) -> Self {
84        self.sync_timing.observer = Some(observer);
85        self
86    }
87    /// 每次业务 Tick 后读现有门槛;不修改恢复状态、队列或持久化语义。
88    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        // 只看本Tick新增Effect及其既有相关上下文:O(1)/Effect,额外状态始终O(1)。
108        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        // Started 快照放在本Tick新增 Effect 已计数之后,才能记录真实的首个待处理屏障。
126        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    /// 在业务移除corr前消费匹配回执;旧轮corr低于单调分配水位,不能关闭新轮屏障。
152    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    /// 单页起点是发出 HTTP Effect;后续 HTTP 返回不能提前关闭本页。
177    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    /// 仅匹配的持久化回执或经过验证的空页可以成功关闭页面。
190    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    /// take 保证错误、断线、停止和重复回执都只产生一次终态。
196    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    /// 仅在恢复仍活跃且距上次快照至少一秒时记录旁路状态;不读取时钟或执行I/O。
205    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    /// 只读取既有状态字段,所有计数均为诊断快照,不参与业务状态推进。
231    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    /// bit=1 表示对应 durable 屏障仍未闭合;该 mask 只用于解释 pending/cancelled。
242    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    /// 指标走 Copy 端口,诊断走既有固定字段旁路;不序列化业务对象。
268    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
306/// 仅列恢复请求与durable提交,不把UI查询/遥测或独立业务命令计入恢复。
307fn 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    /// 已发布旧恢复事件也不能越过后续真实持久化;观察不依赖completion_published。
435    #[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    /// 前连接的最终屏障不能为新连接认证成功或失败。
466    #[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        // 终态也必须在同一测试订阅器作用域中发出,才能核验真实导出的字段。
550        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}