1use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use std::time::Duration;
18
19use tokio::sync::mpsc::error::TrySendError;
20use tracing::{debug, error, info};
21
22use crate::blob::BlobTransitionObserver;
23use crate::clock::ClockRef;
24use crate::config::Config;
25use crate::coven::StoreOpenGuard;
26use crate::keys::MasterKeyCustody;
27use crate::store_dir::StoreDir;
28
29use super::cloud_storage::{BlobPathScheme, CloudSyncStorage};
30use super::cycle::SyncComponents;
31use super::hlc::Hlc;
32use super::loop_policy::{self, LoopWait, SyncLoopReport, SyncLoopSuccess};
33
34#[derive(Debug, thiserror::Error)]
36pub enum SyncLoopError {
37 #[error("sync loop cannot be restarted after its channels were taken")]
40 NotRestartable,
41 #[error("failed to spawn sync loop thread: {0}")]
43 ThreadSpawn(std::io::Error),
44 #[error("sync loop thread panicked")]
46 ThreadPanicked,
47}
48
49#[derive(Debug, Clone)]
65pub enum SyncLoopStatus {
66 Offline,
68 CheckingStorage,
70 Publishing,
72 Synchronized(SyncLoopSuccess),
75 Blocked {
78 success: SyncLoopSuccess,
79 writes: Vec<crate::PendingWrite>,
80 },
81 Failed { error: String },
83}
84
85pub(crate) struct SyncLoopHandle {
87 inner: Arc<SyncLoopInner>,
88 clock: ClockRef,
89 config: Config,
90 store_dir: StoreDir,
91 trigger_tx: tokio::sync::mpsc::Sender<()>,
92 trigger_rx: std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<()>>>,
93 command_tx: tokio::sync::mpsc::Sender<SyncCommand>,
94 command_rx: std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<SyncCommand>>>,
95 stop_tx: tokio::sync::watch::Sender<bool>,
96 stop_rx: std::sync::Mutex<Option<tokio::sync::watch::Receiver<bool>>>,
97 status_tx: tokio::sync::watch::Sender<SyncLoopStatus>,
101 thread_handle: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
102 running: Arc<AtomicBool>,
103}
104
105struct SyncLoopInner {
106 components: SyncComponents,
107 custody: Arc<dyn MasterKeyCustody>,
108 observer: Option<Arc<dyn BlobTransitionObserver>>,
109
110 _open_guard: Arc<StoreOpenGuard>,
116}
117
118type CircleReply<T> =
119 tokio::sync::oneshot::Sender<Result<T, crate::sync::store::CircleOperationError>>;
120
121enum SyncCommand {
122 CreateCircle {
123 name: String,
124 reply: CircleReply<crate::CircleId>,
125 },
126 RenameCircle {
127 circle_id: crate::CircleId,
128 name: String,
129 reply: CircleReply<()>,
130 },
131 AddCircleMember {
132 circle_id: crate::CircleId,
133 member_pubkey: String,
134 role: crate::CircleRole,
135 reply: CircleReply<()>,
136 },
137 RemoveCircleMember {
138 circle_id: crate::CircleId,
139 member_pubkey: String,
140 reply: CircleReply<crate::CircleOperationId>,
141 },
142 ResolveCircleControl {
143 circle_id: crate::CircleId,
144 chosen: crate::CircleControlCoord,
145 reply: CircleReply<()>,
146 },
147 CancelCircleEpochClose {
148 circle_id: crate::CircleId,
149 reply: CircleReply<crate::CircleOperationId>,
150 },
151 ExcludeCircleCloseDevice {
152 circle_id: crate::CircleId,
153 excluded_device_id: crate::StoreDeviceId,
154 reply: CircleReply<()>,
155 },
156 DeleteCircle {
157 circle_id: crate::CircleId,
158 reply: CircleReply<()>,
159 },
160 RetryCircleOperation {
161 operation_id: crate::CircleOperationId,
162 reply: CircleReply<()>,
163 },
164 DiscardCircleOperation {
165 operation_id: crate::CircleOperationId,
166 reply: CircleReply<()>,
167 },
168}
169
170impl SyncLoopHandle {
171 pub(crate) fn new(
172 components: SyncComponents,
173 custody: Arc<dyn MasterKeyCustody>,
174 clock: ClockRef,
175 config: Config,
176 observer: Option<Arc<dyn BlobTransitionObserver>>,
177 open_guard: Arc<StoreOpenGuard>,
178 status_tx: tokio::sync::watch::Sender<SyncLoopStatus>,
179 ) -> Self {
180 let (trigger_tx, trigger_rx) = tokio::sync::mpsc::channel(1);
181 let (command_tx, command_rx) = tokio::sync::mpsc::channel(16);
182 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
183 let store_dir = config.store_dir.clone();
184
185 Self {
186 inner: Arc::new(SyncLoopInner {
187 components,
188 custody,
189 observer,
190 _open_guard: open_guard,
191 }),
192 clock,
193 config,
194 store_dir,
195 trigger_tx,
196 trigger_rx: std::sync::Mutex::new(Some(trigger_rx)),
197 command_tx,
198 command_rx: std::sync::Mutex::new(Some(command_rx)),
199 stop_tx,
200 stop_rx: std::sync::Mutex::new(Some(stop_rx)),
201 status_tx,
202 thread_handle: std::sync::Mutex::new(None),
203 running: Arc::new(AtomicBool::new(false)),
204 }
205 }
206
207 pub(crate) fn start(&self) -> Result<(), SyncLoopError> {
219 if self.running.swap(true, Ordering::AcqRel) {
220 return Ok(());
221 }
222
223 let mut trigger_rx = match self.trigger_rx.lock().unwrap().take() {
224 Some(rx) => rx,
225 None => {
226 self.running.store(false, Ordering::Release);
227 return Err(SyncLoopError::NotRestartable);
228 }
229 };
230 let mut stop_rx = match self.stop_rx.lock().unwrap().take() {
231 Some(rx) => rx,
232 None => {
233 self.running.store(false, Ordering::Release);
234 return Err(SyncLoopError::NotRestartable);
235 }
236 };
237 let mut command_rx = match self.command_rx.lock().unwrap().take() {
238 Some(rx) => rx,
239 None => {
240 self.running.store(false, Ordering::Release);
241 return Err(SyncLoopError::NotRestartable);
242 }
243 };
244
245 let inner = Arc::clone(&self.inner);
246 let status_tx = self.status_tx.clone();
247 let clock = self.clock.clone();
248 let store_dir = self.store_dir.clone();
249 let running = Arc::clone(&self.running);
250
251 let handle = std::thread::Builder::new()
252 .name("coven-sync-loop".to_string())
253 .stack_size(8 * 1024 * 1024)
258 .spawn(move || {
259 let _running_guard = RunningGuard {
260 running: Arc::clone(&running),
261 };
262 let rt = match tokio::runtime::Builder::new_current_thread()
263 .enable_all()
264 .build()
265 {
266 Ok(rt) => rt,
267 Err(e) => {
268 let error = format!("failed to create sync loop runtime: {e}");
269 error!("{error}");
270 status_tx.send_replace(SyncLoopStatus::Failed { error });
271 return;
272 }
273 };
274
275 rt.block_on(async move {
276 let startup_delay = tokio::time::sleep(Duration::from_secs(3));
278 tokio::pin!(startup_delay);
279 loop {
280 tokio::select! {
281 _ = &mut startup_delay => break,
282 changed = stop_rx.changed() => {
283 if changed.is_err() || *stop_rx.borrow() {
284 info!("Sync loop stopped before first cycle");
285 return;
286 }
287 }
288 command = command_rx.recv() => {
289 let Some(command) = command else {
290 info!("Sync command channel closed before first cycle");
291 return;
292 };
293 execute_command(&inner, &store_dir, command).await;
294 }
295 }
296 }
297
298 let mut consecutive_failures: u32 = 0;
299 while running.load(Ordering::Acquire) && !*stop_rx.borrow() {
300 status_tx.send_replace(SyncLoopStatus::CheckingStorage);
301 let reachable = inner
302 .components
303 .storage()
304 .cloud_home()
305 .probe()
306 .await
307 .map_err(crate::sync::storage::StorageError::from);
308 let (decision, status) = match reachable {
309 Err(error) => {
310 let status = storage_check_failure_status(&error);
311 let decision = loop_policy::after_failure(
312 format!("check sync storage: {error}"),
313 consecutive_failures,
314 300,
315 );
316 (decision, status)
317 }
318 Ok(_) => {
319 status_tx.send_replace(SyncLoopStatus::Publishing);
320 let (decision, cycle_went_offline) = match run_single_cycle(&inner, clock.as_ref(), &store_dir).await {
321 Ok(result) => (loop_policy::after_success(result), false),
322 Err(error) => {
323 let offline = error.is_offline();
324 (
325 loop_policy::after_failure(
326 error.to_string(),
327 consecutive_failures,
328 300,
329 ),
330 offline,
331 )
332 }
333 };
334 let status = match &decision.report {
335 SyncLoopReport::Success(success) => {
336 match current_success_status(inner.components.database(), success.clone()).await {
337 Ok(status) => status,
338 Err(error) => SyncLoopStatus::Failed { error },
339 }
340 }
341 SyncLoopReport::Failure(_) if cycle_went_offline => {
342 SyncLoopStatus::Offline
343 }
344 SyncLoopReport::Failure(error) => SyncLoopStatus::Failed {
345 error: error.clone(),
346 },
347 };
348 (decision, status)
349 }
350 };
351 consecutive_failures = decision.consecutive_failures;
352 status_tx.send_replace(status);
353
354 let wait = match decision.wait {
355 LoopWait::Immediate => Duration::ZERO,
356 LoopWait::Idle => Duration::from_secs(super::backoff::backoff_secs(0, 300)),
357 LoopWait::BackoffSecs(secs) => Duration::from_secs(secs),
358 };
359 if matches!(decision.wait, LoopWait::BackoffSecs(_)) {
360 debug!(
361 "Backing off {wait:?} after {consecutive_failures} consecutive failure(s)",
362 );
363 }
364 tokio::select! {
365 _ = tokio::time::sleep(wait) => {}
366 changed = stop_rx.changed() => {
367 if changed.is_err() || *stop_rx.borrow() {
368 info!("Sync loop stop requested");
369 break;
370 }
371 }
372 msg = trigger_rx.recv() => {
373 if msg.is_none() {
374 info!("Sync trigger channel closed, stopping sync loop");
375 break;
376 }
377 }
378 command = command_rx.recv() => {
379 let Some(command) = command else {
380 info!("Sync command channel closed, stopping sync loop");
381 break;
382 };
383 execute_command(&inner, &store_dir, command).await;
384 }
385 }
386 }
387 });
388 })
389 .map_err(|e| {
390 self.running.store(false, Ordering::Release);
391 SyncLoopError::ThreadSpawn(e)
392 })?;
393
394 *self.thread_handle.lock().unwrap() = Some(handle);
395 Ok(())
396 }
397
398 pub(crate) fn is_running(&self) -> bool {
400 self.running.load(Ordering::Acquire)
401 }
402
403 pub(crate) fn stop(&self) -> Result<(), SyncLoopError> {
405 let handle = {
406 let mut guard = self.thread_handle.lock().unwrap();
407 if guard.is_none() && !self.running.load(Ordering::Acquire) {
408 return Ok(());
409 }
410 if self.stop_tx.send(true).is_err() {
411 debug!("sync loop stop requested after stop receiver closed");
412 }
413 self.trigger();
414 guard.take()
415 };
416
417 if let Some(handle) = handle {
418 if handle.join().is_err() {
419 self.running.store(false, Ordering::Release);
420 return Err(SyncLoopError::ThreadPanicked);
421 }
422 }
423 self.running.store(false, Ordering::Release);
424 Ok(())
425 }
426
427 pub(crate) fn trigger(&self) {
433 match self.trigger_tx.try_send(()) {
434 Ok(()) | Err(TrySendError::Full(())) => {}
435 Err(TrySendError::Closed(())) => {
436 debug!("Sync trigger channel closed, loop is not running");
437 }
438 }
439 }
440
441 pub(crate) fn storage(&self) -> &Arc<CloudSyncStorage> {
444 self.inner.components.storage()
445 }
446
447 pub(crate) fn store_dir(&self) -> &StoreDir {
448 &self.store_dir
449 }
450
451 pub(crate) fn config(&self) -> &Config {
452 &self.config
453 }
454
455 pub(crate) fn blob_path_scheme(&self) -> BlobPathScheme {
456 self.inner.components.blob_path_scheme()
457 }
458
459 pub(crate) fn self_uploader(&self) -> String {
460 self.inner.components.self_uploader()
461 }
462
463 pub(crate) fn hlc(&self) -> &Arc<Hlc> {
464 self.inner.components.hlc()
465 }
466
467 pub(crate) fn current_encryption(&self) -> Option<crate::encryption::EncryptionService> {
468 self.inner.components.current_encryption()
469 }
470
471 pub(crate) async fn invite_member(
472 &self,
473 public_key_hex: &str,
474 invitee_email: Option<&str>,
475 role: super::membership::MemberRole,
476 store_name: &str,
477 ) -> Result<crate::join_code::InviteCode, super::store::MembershipOpsError> {
478 self.inner
479 .components
480 .invite_member(public_key_hex, invitee_email, role, store_name)
481 .await
482 }
483
484 pub(crate) async fn remove_member(
485 &self,
486 public_key_hex: &str,
487 ) -> Result<String, super::store::MembershipOpsError> {
488 self.inner
489 .components
490 .remove_member(public_key_hex, self.inner.custody.as_ref())
491 .await
492 }
493
494 pub(crate) async fn resolve_membership_conflict(
495 &self,
496 choice: &crate::MembershipConflictChoice,
497 ) -> Result<(), super::store::MembershipOpsError> {
498 self.inner
499 .components
500 .resolve_membership_conflict(choice)
501 .await
502 }
503
504 #[cfg(test)]
505 pub(crate) fn adopt_key_rotation_for_test(
506 &self,
507 encryption: crate::encryption::EncryptionService,
508 ) -> Result<String, crate::keys::KeyError> {
509 self.inner
510 .components
511 .adopt_key_rotation(encryption, self.inner.custody.as_ref())
512 }
513
514 pub(crate) async fn drain_uploads(
515 &self,
516 ) -> Result<crate::blob::upload::DrainOutcome, crate::database::DbError> {
517 self.inner
518 .components
519 .drain_uploads(
520 self.clock.as_ref(),
521 &self.store_dir,
522 self.inner.observer.as_deref(),
523 )
524 .await
525 }
526
527 async fn send_circle_command<T>(
530 &self,
531 command: impl FnOnce(CircleReply<T>) -> SyncCommand,
532 ) -> Result<T, crate::sync::store::CircleOperationError> {
533 let (reply, response) = tokio::sync::oneshot::channel();
534 self.command_tx
535 .send(command(reply))
536 .await
537 .map_err(|_| crate::sync::store::CircleOperationError::CommandChannelClosed)?;
538 response
539 .await
540 .map_err(|_| crate::sync::store::CircleOperationError::ReplyChannelClosed)?
541 }
542
543 pub(crate) async fn create_circle(
544 &self,
545 name: &str,
546 ) -> Result<crate::CircleId, crate::sync::store::CircleOperationError> {
547 let name = name.to_string();
548 self.send_circle_command(|reply| SyncCommand::CreateCircle { name, reply })
549 .await
550 }
551
552 pub(crate) async fn rename_circle(
553 &self,
554 circle_id: crate::CircleId,
555 name: &str,
556 ) -> Result<(), crate::sync::store::CircleOperationError> {
557 let name = name.to_string();
558 self.send_circle_command(|reply| SyncCommand::RenameCircle {
559 circle_id,
560 name,
561 reply,
562 })
563 .await
564 }
565
566 pub(crate) async fn add_circle_member(
567 &self,
568 circle_id: crate::CircleId,
569 member_pubkey: String,
570 role: crate::CircleRole,
571 ) -> Result<(), crate::sync::store::CircleOperationError> {
572 self.send_circle_command(|reply| SyncCommand::AddCircleMember {
573 circle_id,
574 member_pubkey,
575 role,
576 reply,
577 })
578 .await
579 }
580
581 pub(crate) async fn remove_circle_member(
582 &self,
583 circle_id: crate::CircleId,
584 member_pubkey: String,
585 ) -> Result<crate::CircleOperationId, crate::sync::store::CircleOperationError> {
586 self.send_circle_command(|reply| SyncCommand::RemoveCircleMember {
587 circle_id,
588 member_pubkey,
589 reply,
590 })
591 .await
592 }
593
594 pub(crate) async fn resolve_circle_control(
595 &self,
596 circle_id: crate::CircleId,
597 chosen: crate::CircleControlCoord,
598 ) -> Result<(), crate::sync::store::CircleOperationError> {
599 self.send_circle_command(|reply| SyncCommand::ResolveCircleControl {
600 circle_id,
601 chosen,
602 reply,
603 })
604 .await
605 }
606
607 pub(crate) async fn cancel_circle_epoch_close(
608 &self,
609 circle_id: crate::CircleId,
610 ) -> Result<crate::CircleOperationId, crate::sync::store::CircleOperationError> {
611 self.send_circle_command(|reply| SyncCommand::CancelCircleEpochClose { circle_id, reply })
612 .await
613 }
614
615 pub(crate) async fn exclude_circle_close_device(
616 &self,
617 circle_id: crate::CircleId,
618 excluded_device_id: crate::StoreDeviceId,
619 ) -> Result<(), crate::sync::store::CircleOperationError> {
620 self.send_circle_command(|reply| SyncCommand::ExcludeCircleCloseDevice {
621 circle_id,
622 excluded_device_id,
623 reply,
624 })
625 .await
626 }
627
628 pub(crate) async fn delete_circle(
629 &self,
630 circle_id: crate::CircleId,
631 ) -> Result<(), crate::sync::store::CircleOperationError> {
632 self.send_circle_command(|reply| SyncCommand::DeleteCircle { circle_id, reply })
633 .await
634 }
635
636 pub(crate) async fn retry_circle_operation(
637 &self,
638 operation_id: crate::CircleOperationId,
639 ) -> Result<(), crate::sync::store::CircleOperationError> {
640 self.send_circle_command(|reply| SyncCommand::RetryCircleOperation {
641 operation_id,
642 reply,
643 })
644 .await
645 }
646
647 pub(crate) async fn discard_circle_operation(
648 &self,
649 operation_id: crate::CircleOperationId,
650 ) -> Result<(), crate::sync::store::CircleOperationError> {
651 self.send_circle_command(|reply| SyncCommand::DiscardCircleOperation {
652 operation_id,
653 reply,
654 })
655 .await
656 }
657
658 pub(crate) async fn circle_close_status(
661 &self,
662 circle_id: crate::CircleId,
663 ) -> Result<crate::CircleCloseStatus, crate::sync::store::CircleOperationError> {
664 self.inner.components.circle_close_status(circle_id).await
665 }
666}
667
668async fn execute_command(inner: &SyncLoopInner, store_dir: &StoreDir, command: SyncCommand) {
669 match command {
670 SyncCommand::CreateCircle { name, reply } => {
671 reply_circle_command(reply, inner.components.create_circle(&name).await);
672 }
673 SyncCommand::RenameCircle {
674 circle_id,
675 name,
676 reply,
677 } => {
678 reply_circle_command(
679 reply,
680 inner.components.rename_circle(circle_id, &name).await,
681 );
682 }
683 SyncCommand::AddCircleMember {
684 circle_id,
685 member_pubkey,
686 role,
687 reply,
688 } => {
689 reply_circle_command(
690 reply,
691 inner
692 .components
693 .add_circle_member(store_dir, circle_id, member_pubkey, role)
694 .await,
695 );
696 }
697 SyncCommand::RemoveCircleMember {
698 circle_id,
699 member_pubkey,
700 reply,
701 } => {
702 reply_circle_command(
703 reply,
704 inner
705 .components
706 .remove_circle_member(circle_id, member_pubkey)
707 .await,
708 );
709 }
710 SyncCommand::ResolveCircleControl {
711 circle_id,
712 chosen,
713 reply,
714 } => {
715 reply_circle_command(
716 reply,
717 inner
718 .components
719 .resolve_circle_control(circle_id, chosen)
720 .await,
721 );
722 }
723 SyncCommand::CancelCircleEpochClose { circle_id, reply } => {
724 reply_circle_command(
725 reply,
726 inner.components.cancel_circle_epoch_close(circle_id).await,
727 );
728 }
729 SyncCommand::ExcludeCircleCloseDevice {
730 circle_id,
731 excluded_device_id,
732 reply,
733 } => {
734 reply_circle_command(
735 reply,
736 inner
737 .components
738 .exclude_circle_close_device(circle_id, excluded_device_id)
739 .await,
740 );
741 }
742 SyncCommand::DeleteCircle { circle_id, reply } => {
743 reply_circle_command(reply, inner.components.delete_circle(circle_id).await);
744 }
745 SyncCommand::RetryCircleOperation {
746 operation_id,
747 reply,
748 } => {
749 reply_circle_command(
750 reply,
751 inner.components.retry_circle_operation(&operation_id).await,
752 );
753 }
754 SyncCommand::DiscardCircleOperation {
755 operation_id,
756 reply,
757 } => {
758 reply_circle_command(
759 reply,
760 inner
761 .components
762 .discard_circle_operation(&operation_id)
763 .await,
764 );
765 }
766 }
767}
768
769fn reply_circle_command<T>(
770 reply: CircleReply<T>,
771 result: Result<T, crate::sync::store::CircleOperationError>,
772) {
773 if reply.send(result).is_err() {
774 debug!("Circle command caller dropped its reply receiver");
775 }
776}
777
778fn storage_check_failure_status(error: &crate::sync::storage::StorageError) -> SyncLoopStatus {
779 if error.is_transport() {
780 SyncLoopStatus::Offline
781 } else {
782 SyncLoopStatus::Failed {
783 error: format!("check sync storage: {error}"),
784 }
785 }
786}
787
788async fn current_success_status(
789 db: &crate::database::Database,
790 success: SyncLoopSuccess,
791) -> Result<SyncLoopStatus, String> {
792 let writes: Vec<_> = db
793 .pending_writes()
794 .await
795 .map_err(|error| format!("read pending writes after sync: {error}"))?
796 .into_iter()
797 .filter(|write| matches!(write.status, crate::WriteStatus::Blocked(_)))
798 .collect();
799 if !writes.is_empty() {
800 return Ok(SyncLoopStatus::Blocked { success, writes });
801 }
802 Ok(SyncLoopStatus::Synchronized(success))
803}
804
805struct RunningGuard {
806 running: Arc<AtomicBool>,
807}
808
809impl Drop for RunningGuard {
810 fn drop(&mut self) {
811 self.running.store(false, Ordering::Release);
812 }
813}
814
815async fn run_single_cycle(
817 inner: &SyncLoopInner,
818 clock: &dyn crate::clock::Clock,
819 store_dir: &StoreDir,
820) -> Result<super::cycle::SyncCycleResult, super::cycle::SyncCycleFailure> {
821 inner
822 .components
823 .run_cycle(
824 clock,
825 Some(inner.custody.as_ref()),
826 store_dir,
827 inner.observer.as_deref(),
828 )
829 .await
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835
836 fn success() -> SyncLoopSuccess {
837 SyncLoopSuccess {
838 last_sync_time: "2026-07-14T00:00:00Z".to_string(),
839 device_count: 1,
840 device_activity: Vec::new(),
841 data_changed: false,
842 row_changes: None,
843 alerts: crate::SyncLoopAlerts {
844 rotation_pending: None,
845 held_positions: Vec::new(),
846 asset_downloads_failed: false,
847 local_blob_cleanup_pending: false,
848 },
849 }
850 }
851
852 #[test]
853 fn storage_configuration_failure_is_terminal() {
854 let status = storage_check_failure_status(
855 &crate::sync::storage::StorageError::Configuration("missing bucket".to_string()),
856 );
857
858 assert!(matches!(status, SyncLoopStatus::Failed { .. }));
859 }
860
861 fn database() -> crate::database::Database {
862 coven_core::database::Database::open(
863 std::path::Path::new(":memory:"),
864 Vec::new(),
865 chrono::Duration::days(30),
866 coven_core::blob::TransferLimits::one_at_a_time(),
867 "status-test".to_string(),
868 &[],
869 )
870 .expect("open status test database")
871 .0
872 }
873
874 async fn insert_write_status(
875 db: &crate::database::Database,
876 write_id: &'static str,
877 status: crate::WriteStatus,
878 ) {
879 let base = serde_json::json!({ "dependencies": {} }).to_string();
880 let status = serde_json::to_string(&status).expect("serialize durable write status");
881 db.call(move |conn| {
882 conn.execute(
883 r#"INSERT INTO store_writes
884 (write_id, status, affected_rows, changeset, inverse_changeset, base, blob_facts)
885 VALUES (?1, ?2, '[]', X'', X'', ?3, '{"blobs":[]}')"#,
886 (write_id, status, base),
887 )
888 .map(|_| ())
889 .map_err(crate::DbError::from)
890 })
891 .await
892 .expect("insert durable write status");
893 }
894
895 #[tokio::test]
896 async fn successful_cycle_projects_durable_blocked_state() {
897 let db = database();
898 insert_write_status(
899 &db,
900 "blocked-write",
901 crate::WriteStatus::Blocked(crate::WriteBlock::MissingBlob {
902 namespace: "audio".to_string(),
903 id: "missing".to_string(),
904 }),
905 )
906 .await;
907 let blocked = current_success_status(&db, success())
908 .await
909 .expect("project blocked state");
910 assert!(matches!(
911 blocked,
912 SyncLoopStatus::Blocked { writes, .. }
913 if writes.len() == 1 && writes[0].write_id.as_str() == "blocked-write"
914 ));
915 db.call(|conn| {
916 conn.execute(
917 "DELETE FROM store_writes WHERE write_id = 'blocked-write'",
918 [],
919 )
920 .map(|_| ())
921 .map_err(crate::DbError::from)
922 })
923 .await
924 .expect("remove blocked projection fixture");
925
926 assert!(matches!(
927 current_success_status(&db, success())
928 .await
929 .expect("project synchronized state"),
930 SyncLoopStatus::Synchronized(_)
931 ));
932 }
933}