1use crate::query_mapped_rows;
2use crate::store::device_join_journal::{
3 require_initial, validate_successor, DeviceJoinJournalError,
4};
5use crate::StoreDatabase;
6use coven_protocol::store_commit::device_join_journal::{
7 DeviceJoinAction, DeviceJoinJournalRecord, DeviceJoinRole, DeviceJoinStatus,
8};
9use coven_protocol::store_commit::{DeviceJoinAttemptId, ObjectHash};
10
11#[derive(Clone, Debug)]
20pub struct DeviceJoinJournalStore {
21 path: std::path::PathBuf,
22 durability: crate::connection_io::ConnectionDurability,
23 connection: std::sync::Arc<std::sync::Mutex<Option<rusqlite::Connection>>>,
27}
28
29impl DeviceJoinJournalStore {
30 pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, crate::DbError> {
31 Self::open_with_durability(path, crate::connection_io::ConnectionDurability::Full)
32 }
33
34 #[cfg(any(test, feature = "test-utils"))]
35 pub fn open_for_test(path: impl AsRef<std::path::Path>) -> Result<Self, crate::DbError> {
36 Self::open_with_durability(path, crate::connection_io::ConnectionDurability::Disabled)
37 }
38
39 fn open_with_durability(
40 path: impl AsRef<std::path::Path>,
41 durability: crate::connection_io::ConnectionDurability,
42 ) -> Result<Self, crate::DbError> {
43 let store = Self {
44 path: path.as_ref().to_path_buf(),
45 durability,
46 connection: std::sync::Arc::new(std::sync::Mutex::new(None)),
47 };
48 store.with_connection(|_| Ok(()))?;
52 Ok(store)
53 }
54
55 fn open_into(&self, held: &mut Option<rusqlite::Connection>) -> Result<(), crate::DbError> {
59 if let Some(directory) = self.path.parent() {
60 std::fs::create_dir_all(directory).map_err(crate::DbError::from)?;
61 }
62 let opened = rusqlite::Connection::open(&self.path).map_err(crate::DbError::from)?;
63 crate::connection_io::configure_connection_durability(&opened, self.durability)?;
64 opened
65 .execute_batch(
66 "PRAGMA foreign_keys = ON;
67 CREATE TABLE IF NOT EXISTS device_join_journals (
68 attempt_id TEXT NOT NULL,
69 role TEXT NOT NULL,
70 payload TEXT NOT NULL,
71 PRIMARY KEY (attempt_id, role)
72 ) STRICT, WITHOUT ROWID;",
73 )
74 .map_err(crate::DbError::from)?;
75 *held = Some(opened);
76 Ok(())
77 }
78
79 fn with_connection<R>(
82 &self,
83 operation: impl FnOnce(&rusqlite::Connection) -> Result<R, crate::DbError>,
84 ) -> Result<R, crate::DbError> {
85 let mut held = self
86 .connection
87 .lock()
88 .map_err(|_| pending_join_connection_poisoned())?;
89 if held.is_none() {
90 self.open_into(&mut held)?;
91 }
92 let connection = held.as_ref().ok_or_else(|| {
93 crate::DbError::Message(
94 "pending device-join journal connection is absent after opening it".to_string(),
95 )
96 })?;
97 operation(connection)
98 }
99
100 pub fn insert_or_load(
101 &self,
102 attempt_id: &str,
103 role: &str,
104 payload: &str,
105 ) -> Result<String, crate::DbError> {
106 self.with_connection(|connection| {
107 let transaction = connection
108 .unchecked_transaction()
109 .map_err(crate::DbError::from)?;
110 transaction
111 .execute(
112 "INSERT OR IGNORE INTO device_join_journals (attempt_id, role, payload)
113 VALUES (?1, ?2, ?3)",
114 (attempt_id, role, payload),
115 )
116 .map_err(crate::DbError::from)?;
117 let actual = transaction
118 .query_row(
119 "SELECT payload FROM device_join_journals WHERE attempt_id = ?1 AND role = ?2",
120 (attempt_id, role),
121 |row| row.get(0),
122 )
123 .map_err(crate::DbError::from)?;
124 transaction.commit().map_err(crate::DbError::from)?;
125 Ok(actual)
126 })
127 }
128
129 pub fn load(&self, attempt_id: &str, role: &str) -> Result<Option<String>, crate::DbError> {
130 use rusqlite::OptionalExtension;
131
132 self.with_connection(|connection| {
133 connection
134 .query_row(
135 "SELECT payload FROM device_join_journals WHERE attempt_id = ?1 AND role = ?2",
136 (attempt_id, role),
137 |row| row.get(0),
138 )
139 .optional()
140 .map_err(crate::DbError::from)
141 })
142 }
143
144 pub fn records(&self) -> Result<Vec<(String, String, String)>, crate::DbError> {
145 self.with_connection(|connection| {
146 query_mapped_rows(
147 connection,
148 "SELECT attempt_id, role, payload FROM device_join_journals
149 ORDER BY attempt_id, role",
150 [],
151 |row| {
152 Ok((
153 row.get::<_, String>(0)?,
154 row.get::<_, String>(1)?,
155 row.get::<_, String>(2)?,
156 ))
157 },
158 )
159 .map_err(crate::DbError::from)
160 })
161 }
162
163 pub fn compare_and_swap(
164 &self,
165 attempt_id: &str,
166 role: &str,
167 previous_payload: &str,
168 next_payload: &str,
169 ) -> Result<bool, crate::DbError> {
170 self.with_connection(|connection| {
171 let changed = connection
172 .execute(
173 "UPDATE device_join_journals SET payload = ?1
174 WHERE attempt_id = ?2 AND role = ?3 AND payload = ?4",
175 (next_payload, attempt_id, role, previous_payload),
176 )
177 .map_err(crate::DbError::from)?;
178 Ok(changed == 1)
179 })
180 }
181
182 pub fn compare_and_forget(
196 &self,
197 attempt_id: &str,
198 role: &str,
199 expected_payload: &str,
200 ) -> Result<bool, crate::DbError> {
201 let mut held = self
202 .connection
203 .lock()
204 .map_err(|_| pending_join_connection_poisoned())?;
205 if held.is_none() {
206 self.open_into(&mut held)?;
207 }
208 let connection = held.as_ref().ok_or_else(|| {
209 crate::DbError::Message(
210 "pending device-join journal connection is absent after opening it".to_string(),
211 )
212 })?;
213 let removed = connection
214 .execute(
215 "DELETE FROM device_join_journals
216 WHERE attempt_id = ?1 AND role = ?2 AND payload = ?3",
217 (attempt_id, role, expected_payload),
218 )
219 .map_err(crate::DbError::from)?;
220 if removed != 1 {
221 return Ok(false);
222 }
223 let remaining: i64 = connection
224 .query_row("SELECT COUNT(*) FROM device_join_journals", [], |row| {
225 row.get(0)
226 })
227 .map_err(crate::DbError::from)?;
228 if remaining == 0 {
229 if let Some(connection) = held.take() {
230 connection.close().map_err(|(_, error)| error)?;
231 }
232 remove_pending_join_files(&self.path)?;
233 }
234 Ok(true)
235 }
236
237 #[cfg(test)]
238 fn synchronous_for_test(&self) -> Result<i64, crate::DbError> {
239 self.with_connection(|connection| {
240 connection
241 .query_row("PRAGMA synchronous", [], |row| row.get(0))
242 .map_err(crate::DbError::from)
243 })
244 }
245}
246
247fn remove_pending_join_files(path: &std::path::Path) -> Result<(), crate::DbError> {
252 for candidate in [
253 path.to_path_buf(),
254 std::path::PathBuf::from(format!("{}-wal", path.display())),
255 std::path::PathBuf::from(format!("{}-shm", path.display())),
256 ] {
257 match std::fs::remove_file(&candidate) {
258 Ok(()) => {}
259 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
260 Err(error) => return Err(crate::DbError::from(error)),
261 }
262 }
263 Ok(())
264}
265
266fn pending_join_connection_poisoned() -> crate::DbError {
267 crate::DbError::Message("pending device-join journal connection lock was poisoned".to_string())
268}
269
270pub(crate) fn begin_device_join_on(
271 conn: &rusqlite::Connection,
272 key: &str,
273 value: &str,
274) -> Result<DeviceJoinJournalRecord, crate::DbError> {
275 conn.execute(
276 "INSERT OR IGNORE INTO protocol_state (key, value) VALUES (?1, ?2)",
277 (key, value),
278 )
279 .map_err(crate::DbError::from)?;
280 let actual = crate::required_protocol_state_on(conn, key)?;
281 serde_json::from_str(&actual).map_err(crate::DbError::from)
282}
283
284pub(crate) fn advance_device_join_on(
285 conn: &rusqlite::Connection,
286 key: &str,
287 previous: &str,
288 next: &str,
289) -> Result<usize, crate::DbError> {
290 conn.execute(
291 "UPDATE protocol_state SET value = ?1 WHERE key = ?2 AND value = ?3",
292 (next, key, previous),
293 )
294 .map_err(crate::DbError::from)
295}
296
297pub(crate) fn device_join_records_on(
298 conn: &rusqlite::Connection,
299) -> Result<Vec<(String, String)>, crate::DbError> {
300 let mut statement = conn
301 .prepare(
302 "SELECT key, value FROM protocol_state
303 WHERE key GLOB 'device_join/*' ORDER BY key",
304 )
305 .map_err(crate::DbError::from)?;
306 let rows = statement
307 .query_map([], |row| {
308 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
309 })
310 .map_err(crate::DbError::from)?;
311 rows.collect::<Result<Vec<_>, _>>()
312 .map_err(crate::DbError::from)
313}
314
315pub(crate) fn forget_device_join_on(
316 conn: &rusqlite::Connection,
317 key: &str,
318) -> Result<(), crate::DbError> {
319 conn.execute("DELETE FROM protocol_state WHERE key = ?1", [key])
320 .map(|_| ())
321 .map_err(crate::DbError::from)
322}
323
324impl StoreDatabase {
325 pub fn new_device_join_attempt_id(&self) -> DeviceJoinAttemptId {
326 DeviceJoinAttemptId::from_hash(ObjectHash::digest(
327 self.new_store_write_id().as_str().as_bytes(),
328 ))
329 }
330
331 pub async fn begin_device_join(
332 &self,
333 record: DeviceJoinJournalRecord,
334 ) -> Result<DeviceJoinJournalRecord, DeviceJoinJournalError> {
335 require_initial(&record)?;
336 let key = record.store_key();
337 let value = serde_json::to_string(&record)?;
338 self.call_database(move |session| session.begin_device_join(&key, &value))
339 .await
340 .map_err(DeviceJoinJournalError::Database)
341 }
342
343 pub async fn load_device_join(
344 &self,
345 attempt_id: DeviceJoinAttemptId,
346 role: DeviceJoinRole,
347 ) -> Result<Option<DeviceJoinJournalRecord>, DeviceJoinJournalError> {
348 let key = DeviceJoinJournalRecord::store_key_for(attempt_id, role);
349 let value = self
350 .get_protocol_state(&key)
351 .await
352 .map_err(DeviceJoinJournalError::Database)?;
353 value
354 .map(|value| {
355 serde_json::from_str(&value).map_err(DeviceJoinJournalError::Serialization)
356 })
357 .transpose()
358 }
359
360 pub async fn advance_device_join(
361 &self,
362 previous: &DeviceJoinJournalRecord,
363 next: DeviceJoinJournalRecord,
364 ) -> Result<(), DeviceJoinJournalError> {
365 use coven_protocol::store_commit::device_join_journal::{
366 DeviceJoinRoleProgress, OwnerJoinProgress,
367 };
368
369 if [&*previous.progress, &*next.progress]
373 .into_iter()
374 .any(|progress| {
375 matches!(
376 progress,
377 DeviceJoinRoleProgress::Owner(OwnerJoinProgress::StorePublicationPrepared(_))
378 )
379 })
380 {
381 return Err(DeviceJoinJournalError::NonAdjacentJournalTransition);
382 }
383 validate_successor(previous, &next)?;
384 let key = previous.store_key();
385 let previous = serde_json::to_string(previous)?;
386 let next = serde_json::to_string(&next)?;
387 let changed = self
388 .call_database(move |session| session.advance_device_join(&key, &previous, &next))
389 .await
390 .map_err(DeviceJoinJournalError::Database)?;
391 if changed == 1 {
392 Ok(())
393 } else {
394 Err(DeviceJoinJournalError::JournalConflict)
395 }
396 }
397
398 async fn device_join_records(
409 &self,
410 ) -> Result<Vec<DeviceJoinJournalRecord>, DeviceJoinJournalError> {
411 let rows = self
412 .call_database(|session| session.device_join_records())
413 .await
414 .map_err(DeviceJoinJournalError::Database)?;
415 let mut records = Vec::with_capacity(rows.len());
416 for (key, value) in rows {
417 let record: DeviceJoinJournalRecord = match serde_json::from_str(&value) {
418 Ok(record) => record,
419 Err(error) => {
420 tracing::warn!(
421 journal_key = %key,
422 %error,
423 "Skipping a device join journal record this binary cannot read"
424 );
425 continue;
426 }
427 };
428 if record.store_key() != key {
429 tracing::warn!(
430 journal_key = %key,
431 record_key = %record.store_key(),
432 "Skipping a device join journal record stored under another attempt's key"
433 );
434 continue;
435 }
436 records.push(record);
437 }
438 records.sort_by_key(DeviceJoinJournalRecord::sort_key);
439 Ok(records)
440 }
441
442 pub async fn device_join_status(
443 &self,
444 attempt_id: DeviceJoinAttemptId,
445 role: DeviceJoinRole,
446 ) -> Result<Option<DeviceJoinStatus>, DeviceJoinJournalError> {
447 self.load_device_join(attempt_id, role)
448 .await
449 .map(|record| record.as_ref().map(DeviceJoinJournalRecord::status))
450 }
451
452 pub async fn device_join_actions(
453 &self,
454 ) -> Result<Vec<DeviceJoinAction>, DeviceJoinJournalError> {
455 Ok(self
456 .device_join_records()
457 .await?
458 .iter()
459 .filter_map(DeviceJoinJournalRecord::action)
460 .collect())
461 }
462
463 pub async fn owner_device_joins_awaiting_arrival(
469 &self,
470 ) -> Result<
471 Vec<(
472 DeviceJoinAttemptId,
473 coven_protocol::store_commit::StoreDeviceRegistrationRef,
474 )>,
475 DeviceJoinJournalError,
476 > {
477 use coven_protocol::store_commit::device_join_journal::{
478 DeviceJoinRoleProgress, OwnerJoinProgress,
479 };
480
481 Ok(self
482 .device_join_records()
483 .await?
484 .into_iter()
485 .filter_map(|record| match &*record.progress {
486 DeviceJoinRoleProgress::Owner(
491 OwnerJoinProgress::ActivationPrepared { registration, .. }
492 | OwnerJoinProgress::SamePrincipalCompleted { registration, .. },
493 ) => Some((record.attempt_id, registration.clone())),
494 _ => None,
495 })
496 .collect())
497 }
498
499 pub async fn retire_device_join(
506 &self,
507 attempt_id: DeviceJoinAttemptId,
508 role: DeviceJoinRole,
509 ) -> Result<(), DeviceJoinJournalError> {
510 let key = DeviceJoinJournalRecord::store_key_for(attempt_id, role);
511 self.call_database(move |session| session.forget_device_join(&key))
512 .await
513 .map_err(DeviceJoinJournalError::Database)
514 }
515
516 #[cfg(any(test, feature = "test-utils"))]
517 pub async fn forget_for_test(
518 &self,
519 attempt_id: DeviceJoinAttemptId,
520 role: DeviceJoinRole,
521 ) -> Result<(), DeviceJoinJournalError> {
522 let key = DeviceJoinJournalRecord::store_key_for(attempt_id, role);
523 self.call_database(move |session| session.forget_device_join(&key))
524 .await
525 .map_err(DeviceJoinJournalError::Database)
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn pending_join_test_store_disables_commit_durability() {
535 let pending_dir = tempfile::tempdir().expect("create pending join directory");
536 let pending =
537 DeviceJoinJournalStore::open_for_test(pending_dir.path().join("pending.sqlite"))
538 .expect("open pending join journal");
539
540 let synchronous = pending
541 .synchronous_for_test()
542 .expect("read synchronous setting");
543
544 assert_eq!(synchronous, 0);
545 }
546
547 #[test]
548 fn retiring_the_last_attempt_deletes_the_journal_file() {
549 let pending_dir = tempfile::tempdir().expect("create pending join directory");
550 let path = pending_dir.path().join("pending.sqlite");
551 let pending = DeviceJoinJournalStore::open(&path).expect("open pending join journal");
552 pending
553 .insert_or_load("attempt-one", "joiner", "first")
554 .expect("begin the first attempt");
555 pending
556 .insert_or_load("attempt-two", "joiner", "second")
557 .expect("begin the second attempt");
558
559 assert!(pending
560 .compare_and_forget("attempt-one", "joiner", "first")
561 .expect("retire the first attempt"));
562 assert!(
563 path.exists(),
564 "a journal still holding an attempt keeps its file"
565 );
566
567 assert!(pending
568 .compare_and_forget("attempt-two", "joiner", "second")
569 .expect("retire the last attempt"));
570 assert!(!path.exists(), "an emptied journal deletes its file");
571 for sidecar in ["pending.sqlite-wal", "pending.sqlite-shm"] {
572 assert!(
573 !pending_dir.path().join(sidecar).exists(),
574 "an emptied journal deletes its {sidecar} sidecar"
575 );
576 }
577 }
578
579 #[test]
580 fn a_journal_used_after_its_file_was_deleted_writes_a_new_one() {
581 let pending_dir = tempfile::tempdir().expect("create pending join directory");
582 let path = pending_dir.path().join("pending.sqlite");
583 let pending = DeviceJoinJournalStore::open(&path).expect("open pending join journal");
584 pending
585 .insert_or_load("attempt-one", "joiner", "first")
586 .expect("begin the first attempt");
587 pending
588 .compare_and_forget("attempt-one", "joiner", "first")
589 .expect("retire the first attempt");
590
591 pending
592 .insert_or_load("attempt-two", "joiner", "second")
593 .expect("a later attempt reopens the deleted journal");
594
595 assert!(path.exists());
596 assert_eq!(
597 DeviceJoinJournalStore::open(&path)
598 .expect("reopen the journal")
599 .records()
600 .expect("read the reopened journal"),
601 vec![(
602 "attempt-two".to_string(),
603 "joiner".to_string(),
604 "second".to_string()
605 )],
606 "the later attempt is durable in the new file, not an unlinked one"
607 );
608 }
609
610 #[test]
611 fn a_failed_retire_leaves_the_journal_file_alone() {
612 let pending_dir = tempfile::tempdir().expect("create pending join directory");
613 let path = pending_dir.path().join("pending.sqlite");
614 let pending = DeviceJoinJournalStore::open(&path).expect("open pending join journal");
615 pending
616 .insert_or_load("attempt-one", "joiner", "first")
617 .expect("begin the attempt");
618
619 assert!(!pending
620 .compare_and_forget("attempt-one", "joiner", "stale")
621 .expect("refuse to retire a row whose payload moved on"));
622
623 assert!(path.exists());
624 assert_eq!(
625 pending
626 .load("attempt-one", "joiner")
627 .expect("read the attempt"),
628 Some("first".to_string())
629 );
630 }
631}