1use std::path::{Path, PathBuf};
2
3use rusqlite::{Connection, OptionalExtension};
4use tracing::info;
5
6use crate::*;
7use coven_protocol::synced_schema::SyncedTable;
8
9use super::*;
10
11pub struct CreatedSnapshot {
12 db_image: SnapshotDatabaseImage,
13 blobs: Vec<SnapshotBlobFact>,
14}
15
16impl CreatedSnapshot {
17 pub fn new(db_image: SnapshotDatabaseImage, blobs: Vec<SnapshotBlobFact>) -> Self {
18 Self { db_image, blobs }
19 }
20
21 pub fn blobs(&self) -> &[SnapshotBlobFact] {
22 &self.blobs
23 }
24
25 pub async fn read_image(&self) -> Result<Vec<u8>, SnapshotImageError> {
26 self.db_image.read().await
27 }
28
29 pub fn into_parts(self) -> (SnapshotDatabaseImage, Vec<SnapshotBlobFact>) {
30 (self.db_image, self.blobs)
31 }
32
33 #[cfg(any(test, feature = "test-utils"))]
34 pub fn image_path_for_test(&self) -> &Path {
35 self.db_image.path()
36 }
37}
38
39#[derive(Debug, Clone)]
40pub struct SnapshotBlobFact {
41 pub fact: crate::StoreWriteBlobFact,
42 pub audience: SnapshotBlobAudience,
43}
44
45#[derive(Debug, Clone)]
46pub enum SnapshotBlobAudience {
47 Store,
48 Circle {
49 circle_id: coven_protocol::circle::CircleId,
50 control: crate::CirclePartitionControl,
51 },
52}
53
54#[derive(Debug, thiserror::Error)]
55pub enum SnapshotImageError {
56 #[error("IO error: {0}")]
57 Io(#[from] std::io::Error),
58 #[error("no synced tables registered; refusing to emit an all-cleared snapshot")]
59 NoSyncedTables,
60 #[error("failed to scope snapshot down to shareable data: {0}")]
61 Projection(String),
62 #[error("snapshot database: {0}")]
63 Database(#[source] Box<DbError>),
64 #[error("snapshot gate: {0}")]
65 Gate(#[from] crate::GateError),
66 #[error("snapshot row routing key: {0}")]
67 RowRoutingKey(#[from] coven_protocol::circle::RowRoutingKeyError),
68 #[error("snapshot SQLite: {0}")]
69 Sqlite(#[from] rusqlite::Error),
70 #[error("snapshot remote object: {0}")]
71 RemoteObject(#[from] coven_protocol::remote_object::RemoteObjectRecordError),
72 #[error("snapshot blob declarations: {0}")]
73 BlobDecl(#[from] crate::BlobDeclError),
74 #[error("snapshot routing contract: {0}")]
75 RoutingContract(#[from] crate::SyncRoutingContractError),
76 #[error("snapshot projection {operation}: {source}")]
77 ProjectionSqlite {
78 operation: String,
79 #[source]
80 source: rusqlite::Error,
81 },
82 #[error("snapshot projection {operation}: {source}")]
83 ProjectionDatabase {
84 operation: String,
85 #[source]
86 source: Box<DbError>,
87 },
88 #[error("snapshot projection {operation}: {source}")]
89 ProjectionIo {
90 operation: String,
91 #[source]
92 source: std::io::Error,
93 },
94 #[error("snapshot projection {operation}: {source}")]
95 ProjectionPayloadStore {
96 operation: String,
97 #[source]
98 source: crate::PayloadStoreError,
99 },
100 #[error("snapshot blob {namespace}/{id} plaintext hash: {source}")]
101 BlobHash {
102 namespace: String,
103 id: String,
104 #[source]
105 source: coven_foundation::object_hash::InvalidObjectHash,
106 },
107 #[error(
108 "could not remove staged snapshot database {path}: {cleanup}",
109 path = .path.display()
110 )]
111 Cleanup { path: PathBuf, cleanup: String },
112 #[error(
113 "snapshot operation failed and staged database {path} could not be removed: {cleanup} \
114 (operation error: {cause})",
115 path = .path.display()
116 )]
117 CleanupAfterFailure {
118 path: PathBuf,
119 cleanup: String,
120 cause: Box<SnapshotImageError>,
121 },
122}
123
124impl From<DbError> for SnapshotImageError {
125 fn from(error: DbError) -> Self {
126 Self::Database(Box::new(error))
127 }
128}
129
130#[derive(Debug)]
131pub enum SnapshotImageOperationError<E> {
132 Operation(E),
133 Cleanup {
134 path: PathBuf,
135 cleanup: String,
136 },
137 CleanupAfterFailure {
138 path: PathBuf,
139 cleanup: String,
140 cause: E,
141 },
142}
143
144#[derive(Debug)]
149pub struct SnapshotDatabaseImage {
150 path: PathBuf,
151 armed: bool,
152}
153
154impl SnapshotDatabaseImage {
155 pub fn prepare(path: PathBuf) -> Result<Self, SnapshotImageError> {
156 let mut staged = Self { path, armed: true };
157 if let Err(cleanup) = staged.remove_files() {
158 staged.armed = false;
159 return Err(SnapshotImageError::Cleanup {
160 path: staged.path.clone(),
161 cleanup: cleanup.to_string(),
162 });
163 }
164 Ok(staged)
165 }
166
167 pub fn create(path: PathBuf, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
168 if let Some(parent) = path.parent() {
169 std::fs::create_dir_all(parent)?;
170 }
171 Self { path, armed: false }.write_new(plaintext)
172 }
173
174 pub fn replace(path: PathBuf, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
175 Self::prepare(path)?.write_new(plaintext)
176 }
177
178 fn prepare_snapshot(temp_dir: &Path) -> Result<Self, SnapshotImageError> {
179 Self::prepare(temp_dir.join("snapshot.db"))
180 }
181
182 pub(super) fn capture_on(
183 self,
184 connection: &rusqlite::Connection,
185 store_dir: &coven_foundation::store_dir::StoreDir,
186 root: &coven_protocol::store_commit::StoreRootRef,
187 tables: &[SyncedTable],
188 routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
189 audience: &coven_protocol::circle::Audience,
190 ) -> Result<CreatedSnapshot, SnapshotImageError> {
191 if tables.is_empty() {
192 return self.finish(Err(SnapshotImageError::NoSyncedTables));
193 }
194 let gates = match crate::Gates::from_tables(connection, tables) {
195 Ok(gates) => gates,
196 Err(error) => {
197 return self.finish(Err(SnapshotImageError::from(error)));
198 }
199 };
200 let routing_key = if gates.has_scoped_graph() {
201 let encryption = match routing_encryption {
202 Some(encryption) => encryption,
203 None => {
204 return self.finish(Err(SnapshotImageError::Projection(
205 "scoped snapshot creation requires Store routing encryption".to_string(),
206 )));
207 }
208 };
209 match coven_protocol::circle::derive_row_routing_key(encryption, root.store_root_hash) {
210 Ok(routing_key) => Some(routing_key),
211 Err(error) => {
212 return self.finish(Err(SnapshotImageError::from(error)));
213 }
214 }
215 } else {
216 None
217 };
218
219 let source_image = match crate::connection_io::serialize_database_image(connection) {
220 Ok(image) => image,
221 Err(error) => {
222 return self.finish(Err(SnapshotImageError::from(error)));
223 }
224 };
225 let mut snapshot = match Connection::open_in_memory().map_err(DbError::from) {
226 Ok(snapshot) => snapshot,
227 Err(error) => {
228 return self.finish(Err(SnapshotImageError::from(error)));
229 }
230 };
231 if let Err(error) =
232 crate::connection_io::deserialize_database_image_into(&mut snapshot, &source_image)
233 {
234 return self.finish(Err(SnapshotImageError::from(error)));
235 }
236 if let Err(error) = Self::project(
237 &mut snapshot,
238 store_dir,
239 root,
240 tables,
241 routing_key.as_ref(),
242 audience,
243 ) {
244 return self.finish(Err(error));
245 }
246 let blobs = match Self::blob_facts(connection, &snapshot, tables) {
247 Ok(blobs) => blobs,
248 Err(error) => return self.finish(Err(error)),
249 };
250 if matches!(audience, coven_protocol::circle::Audience::Circle(_)) {
251 if let Err(error) = Self::strip_circle_transport_state(&mut snapshot) {
252 return self.finish(Err(error));
253 }
254 }
255
256 let image = match crate::connection_io::serialize_database_image(&snapshot) {
257 Ok(image) => image,
258 Err(error) => {
259 return self.finish(Err(SnapshotImageError::from(error)));
260 }
261 };
262 drop(snapshot);
263 let snapshot = self.write_new(&image)?;
264
265 let plaintext_size = match std::fs::metadata(snapshot.path()) {
266 Ok(metadata) => metadata.len(),
267 Err(error) => return snapshot.finish(Err(SnapshotImageError::Io(error))),
268 };
269 info!(plaintext_size, "created snapshot");
270 Ok(CreatedSnapshot::new(snapshot, blobs))
271 }
272
273 fn write_new(mut self, plaintext: &[u8]) -> Result<Self, SnapshotImageError> {
274 let mut file = match std::fs::OpenOptions::new()
275 .write(true)
276 .create_new(true)
277 .open(&self.path)
278 {
279 Ok(file) => file,
280 Err(error) => {
281 self.armed = false;
282 return Err(SnapshotImageError::Io(error));
283 }
284 };
285 self.armed = true;
286 if let Err(error) = std::io::Write::write_all(&mut file, plaintext) {
287 drop(file);
288 return self.finish(Err(SnapshotImageError::Io(error)));
289 }
290 drop(file);
291 Ok(self)
292 }
293
294 pub fn path(&self) -> &Path {
295 &self.path
296 }
297
298 pub async fn read(&self) -> Result<Vec<u8>, SnapshotImageError> {
299 tokio::fs::read(&self.path)
300 .await
301 .map_err(|error| SnapshotImageError::ProjectionIo {
302 operation: format!("read staged snapshot database {}", self.path.display()),
303 source: error,
304 })
305 }
306
307 pub fn read_and_discard(self) -> Result<Vec<u8>, SnapshotImageError> {
308 let outcome = std::fs::read(&self.path).map_err(SnapshotImageError::Io);
309 self.finish(outcome)
310 }
311
312 pub fn canonicalize(mut self) -> Result<Self, SnapshotImageError> {
313 match std::fs::canonicalize(&self.path) {
314 Ok(path) => {
315 self.path = path;
316 Ok(self)
317 }
318 Err(error) => self.finish(Err(SnapshotImageError::Io(error))),
319 }
320 }
321
322 pub fn finish<T>(
323 self,
324 outcome: Result<T, SnapshotImageError>,
325 ) -> Result<T, SnapshotImageError> {
326 match self.finish_operation(outcome) {
327 Ok(value) => Ok(value),
328 Err(SnapshotImageOperationError::Operation(cause)) => Err(cause),
329 Err(SnapshotImageOperationError::Cleanup { path, cleanup }) => {
330 Err(SnapshotImageError::Cleanup { path, cleanup })
331 }
332 Err(SnapshotImageOperationError::CleanupAfterFailure {
333 path,
334 cleanup,
335 cause,
336 }) => Err(SnapshotImageError::CleanupAfterFailure {
337 path,
338 cleanup,
339 cause: Box::new(cause),
340 }),
341 }
342 }
343
344 pub fn finish_operation<T, E>(
345 mut self,
346 outcome: Result<T, E>,
347 ) -> Result<T, SnapshotImageOperationError<E>> {
348 let cleanup = self.remove_files();
349 self.armed = false;
350 match (outcome, cleanup) {
351 (Ok(value), Ok(())) => Ok(value),
352 (Err(cause), Ok(())) => Err(SnapshotImageOperationError::Operation(cause)),
353 (Ok(_), Err(cleanup)) => Err(SnapshotImageOperationError::Cleanup {
354 path: self.path.clone(),
355 cleanup: cleanup.to_string(),
356 }),
357 (Err(cause), Err(cleanup)) => Err(SnapshotImageOperationError::CleanupAfterFailure {
358 path: self.path.clone(),
359 cleanup: cleanup.to_string(),
360 cause,
361 }),
362 }
363 }
364
365 pub fn commit(mut self) -> PathBuf {
366 self.armed = false;
367 std::mem::take(&mut self.path)
368 }
369
370 fn project(
371 connection: &mut Connection,
372 store_dir: &coven_foundation::store_dir::StoreDir,
373 root: &coven_protocol::store_commit::StoreRootRef,
374 synced: &[SyncedTable],
375 routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
376 audience: &coven_protocol::circle::Audience,
377 ) -> Result<(), SnapshotImageError> {
378 let gates =
379 crate::Gates::from_tables(connection, synced).map_err(SnapshotImageError::from)?;
380 if gates.has_scoped_graph() && routing_key.is_none() {
381 return Err(SnapshotImageError::Projection(
382 "scoped snapshot projection requires a row-routing key".to_string(),
383 ));
384 }
385 let transaction = connection
386 .unchecked_transaction()
387 .map_err(SnapshotImageError::from)?;
388 transaction
389 .pragma_update(None, "defer_foreign_keys", "ON")
390 .map_err(SnapshotImageError::from)?;
391 let coverage =
392 crate::store::materialized_commit_index::materialized_frontier_on(&transaction, None)
393 .map_err(SnapshotImageError::from)?;
394 let cleared_materialization_tables = ["materialized_commits"];
395 for table in cleared_materialization_tables {
396 transaction
397 .execute_batch(&format!("DELETE FROM {}", crate::quote_ident(table)))
398 .map_err(|error| SnapshotImageError::ProjectionSqlite {
399 operation: format!("clear {table}"),
400 source: error,
401 })?;
402 }
403 if matches!(audience, coven_protocol::circle::Audience::Store) {
404 let records =
405 crate::store::store_session::StoreTransaction::new(&transaction, store_dir);
406 let mut authority = super::VerifiedStoreAuthority::default();
407 records
408 .retain_snapshot_replay_inputs(&mut authority, root)
409 .map_err(SnapshotImageError::from)?;
410 records
411 .retain_snapshot_device_states(&mut authority, root, coverage)
412 .map_err(SnapshotImageError::from)?;
413 }
414 let preserved_non_synced_tables = match audience {
415 coven_protocol::circle::Audience::Store => SNAPSHOT_PRESERVED_NON_SYNCED_TABLES,
416 coven_protocol::circle::Audience::Circle(_) => CIRCLE_IMAGE_PRESERVED_NON_SYNCED_TABLES,
417 coven_protocol::circle::Audience::Local => {
418 return Err(SnapshotImageError::Projection(
419 "Local rows cannot enter a snapshot".to_string(),
420 ));
421 }
422 };
423 for table in crate::user_table_names(connection).map_err(|error| {
424 SnapshotImageError::ProjectionSqlite {
425 operation: "list user tables".to_string(),
426 source: error,
427 }
428 })? {
429 if synced.iter().any(|synced| synced.name() == table)
430 || preserved_non_synced_tables.contains(&table.as_str())
431 || cleared_materialization_tables.contains(&table.as_str())
432 {
433 continue;
434 }
435 transaction
436 .execute_batch(&format!("DELETE FROM {}", crate::quote_ident(&table)))
437 .map_err(|error| SnapshotImageError::ProjectionSqlite {
438 operation: format!("clear {table}"),
439 source: error,
440 })?;
441 }
442
443 match audience {
444 coven_protocol::circle::Audience::Store => gates
445 .delete_gated_false(&transaction)
446 .map_err(SnapshotImageError::from)?,
447 coven_protocol::circle::Audience::Circle(_) => {
448 crate::retain_snapshot_audience_rows(&transaction, &gates, audience)
449 .map_err(SnapshotImageError::from)?;
450 }
451 coven_protocol::circle::Audience::Local => {
452 return Err(SnapshotImageError::Projection(
453 "Local rows cannot enter a snapshot".to_string(),
454 ));
455 }
456 }
457 if let Some(routing_key) = routing_key {
458 crate::prune_private_routes_without_rows(&transaction, &gates)
459 .map_err(SnapshotImageError::from)?;
460 crate::validate_snapshot_routing_state(&transaction, &gates, routing_key, audience)
461 .map_err(SnapshotImageError::from)?;
462 }
463
464 scope_authenticated_blob_graph(&transaction, synced)?;
465 transaction.commit().map_err(SnapshotImageError::from)?;
466 if matches!(audience, coven_protocol::circle::Audience::Store) {
467 connection.execute_batch("VACUUM").map_err(|error| {
468 SnapshotImageError::ProjectionSqlite {
469 operation: "vacuum".to_string(),
470 source: error,
471 }
472 })?;
473 }
474 Ok(())
475 }
476
477 fn strip_circle_transport_state(connection: &mut Connection) -> Result<(), SnapshotImageError> {
478 connection
479 .pragma_update(None, "foreign_keys", "ON")
480 .map_err(SnapshotImageError::from)?;
481 let transaction = connection.transaction().map_err(SnapshotImageError::from)?;
482 transaction
485 .execute_batch(
486 "DELETE FROM row_blob_locators;
487 DELETE FROM blob_locators;
488 DELETE FROM retained_replay_objects;
489 DELETE FROM remote_objects;
490 DELETE FROM retained_merge_materializations;",
491 )
492 .map_err(|error| SnapshotImageError::ProjectionSqlite {
493 operation: "strip Circle snapshot transport state".to_string(),
494 source: error,
495 })?;
496 transaction.commit().map_err(SnapshotImageError::from)?;
497 connection.execute_batch("VACUUM").map_err(|error| {
498 SnapshotImageError::ProjectionSqlite {
499 operation: "vacuum Circle snapshot transport projection".to_string(),
500 source: error,
501 }
502 })?;
503 Ok(())
504 }
505
506 pub fn install_blob_graph(
507 self,
508 blobs: &[crate::PreparedSnapshotBlob],
509 ) -> Result<Self, SnapshotImageError> {
510 let result = (|| {
511 let source = std::fs::read(self.path()).map_err(SnapshotImageError::Io)?;
512 let mut connection = Connection::open_in_memory()
513 .map_err(DbError::from)
514 .map_err(SnapshotImageError::from)?;
515 crate::connection_io::deserialize_database_image_into(&mut connection, &source)
516 .map_err(SnapshotImageError::from)?;
517 connection
518 .pragma_update(None, "foreign_keys", "ON")
519 .map_err(SnapshotImageError::from)?;
520 let transaction = connection.transaction().map_err(SnapshotImageError::from)?;
521 for blob in blobs {
522 blob.remote.validate().map_err(SnapshotImageError::from)?;
523 if blob.bindings.is_empty()
524 || blob
525 .bindings
526 .iter()
527 .any(|binding| binding.blob().object() != blob.remote.object())
528 {
529 return Err(SnapshotImageError::Projection(
530 "snapshot blob binding differs from its remote object".to_string(),
531 ));
532 }
533 crate::install_snapshot_blob_plan_on(&transaction, blob).map_err(|error| {
534 SnapshotImageError::ProjectionDatabase {
535 operation: "install snapshot blob".to_string(),
536 source: Box::new(error),
537 }
538 })?;
539 }
540 transaction.commit().map_err(SnapshotImageError::from)?;
541 connection.execute_batch("VACUUM").map_err(|error| {
542 SnapshotImageError::ProjectionSqlite {
543 operation: "vacuum snapshot closure".to_string(),
544 source: error,
545 }
546 })?;
547 let image = crate::connection_io::serialize_database_image(&connection)
548 .map_err(SnapshotImageError::from)?;
549 connection
550 .close()
551 .map_err(|(_, error)| SnapshotImageError::ProjectionSqlite {
552 operation: "close snapshot closure image".to_string(),
553 source: error,
554 })?;
555 let mut file = std::fs::OpenOptions::new()
556 .write(true)
557 .truncate(true)
558 .open(self.path())
559 .map_err(SnapshotImageError::Io)?;
560 std::io::Write::write_all(&mut file, &image).map_err(SnapshotImageError::Io)?;
561 Ok(())
562 })();
563 match result {
564 Ok(()) => Ok(self),
565 Err(error) => self.finish(Err(error)),
566 }
567 }
568
569 fn blob_facts(
570 live: &Connection,
571 snapshot: &Connection,
572 tables: &[SyncedTable],
573 ) -> Result<Vec<SnapshotBlobFact>, SnapshotImageError> {
574 let declarations =
575 crate::BlobDecls::from_tables(snapshot, tables).map_err(SnapshotImageError::from)?;
576 let publications = declarations
577 .publication_blobs_in_db(snapshot)
578 .map_err(SnapshotImageError::from)?;
579 let gates = crate::Gates::from_tables(live, tables).map_err(SnapshotImageError::from)?;
580 let mut facts = Vec::with_capacity(publications.len());
581 for publication in publications {
582 let plaintext_hash = publication.plaintext_hash.parse().map_err(|error| {
583 SnapshotImageError::BlobHash {
584 namespace: publication.blob.namespace.clone(),
585 id: publication.blob.id.clone(),
586 source: error,
587 }
588 })?;
589 let external_path =
590 if publication.blob.provenance == coven_protocol::blob::Provenance::UserProvided {
591 live.query_row(
592 "SELECT path FROM local_blob_refs
593 WHERE table_name = ?1 AND row_id = ?2 AND column_name = ?3
594 AND row_stamp = ?4 AND namespace = ?5 AND blob_id = ?6",
595 rusqlite::params![
596 publication.table,
597 publication.row_id,
598 publication.column,
599 publication.row_stamp,
600 publication.blob.namespace,
601 publication.blob.id,
602 ],
603 |row| row.get::<_, String>(0),
604 )
605 .optional()
606 .map_err(SnapshotImageError::from)?
607 .map(PathBuf::from)
608 } else {
609 None
610 };
611 let previous = crate::previous_row_blob_for_write_on(
612 snapshot,
613 &publication.table,
614 &publication.row_id,
615 &publication.row_stamp,
616 &publication.column,
617 &publication.blob,
618 publication.plaintext_size,
619 plaintext_hash,
620 )
621 .map_err(SnapshotImageError::from)?;
622 let audience = match crate::live_row_audience(
623 live,
624 &gates,
625 &publication.table,
626 &publication.row_id,
627 )
628 .map_err(SnapshotImageError::from)?
629 {
630 coven_protocol::circle::Audience::Store => SnapshotBlobAudience::Store,
631 coven_protocol::circle::Audience::Circle(circle_id) => {
632 SnapshotBlobAudience::Circle {
633 circle_id,
634 control: crate::active_circle_control(live, circle_id)
635 .map_err(SnapshotImageError::from)?,
636 }
637 }
638 coven_protocol::circle::Audience::Local => {
639 return Err(SnapshotImageError::Projection(format!(
640 "scoped snapshot retains local blob row {:?}/{:?}",
641 publication.table, publication.row_id
642 )));
643 }
644 };
645 facts.push(SnapshotBlobFact {
646 fact: crate::StoreWriteBlobFact {
647 table: publication.table,
648 row_id: publication.row_id,
649 row_stamp: publication.row_stamp,
650 column: publication.column,
651 blob: publication.blob,
652 plaintext_size: publication.plaintext_size,
653 plaintext_hash,
654 external_path,
655 previous,
656 audience_move: None,
657 },
658 audience,
659 });
660 }
661 Ok(facts)
662 }
663
664 fn remove_files(&self) -> std::io::Result<()> {
665 for candidate in [
666 self.path.clone(),
667 PathBuf::from(format!("{}-wal", self.path.display())),
668 PathBuf::from(format!("{}-shm", self.path.display())),
669 ] {
670 match std::fs::remove_file(candidate) {
671 Ok(()) => {}
672 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
673 Err(error) => return Err(error),
674 }
675 }
676 Ok(())
677 }
678}
679
680impl Drop for SnapshotDatabaseImage {
681 fn drop(&mut self) {
682 if !self.armed {
683 return;
684 }
685 if let Err(error) = self.remove_files() {
686 tracing::warn!(
687 path = %self.path.display(),
688 %error,
689 "could not remove abandoned staged snapshot database"
690 );
691 }
692 }
693}
694
695impl StoreSession<'_> {
696 fn capture_snapshot_cut(
697 &self,
698 root: &coven_protocol::store_commit::StoreRootRef,
699 temp_dir: &Path,
700 routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
701 audience: coven_protocol::circle::Audience,
702 ) -> Result<
703 (
704 CreatedSnapshot,
705 coven_protocol::store_commit::CommitFrontier,
706 ),
707 DbError,
708 > {
709 let records = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir);
710 require_no_unpublished_store_writes(self.conn)?;
711 let snapshot = SnapshotDatabaseImage::prepare_snapshot(temp_dir)
712 .and_then(|image| {
713 records.capture_snapshot(
714 image,
715 root,
716 self.synced_tables,
717 routing_encryption,
718 &audience,
719 )
720 })
721 .map_err(snapshot_image_db_error)?;
722 let coverage = coven_protocol::store_commit::CommitFrontier::from_refs(
723 crate::store::materialized_commit_index::materialized_frontier_on(self.conn, None)?,
724 )
725 .map_err(|error| DbError::context("snapshot coverage", error))?;
726 Ok((snapshot, coverage))
727 }
728
729 pub(super) fn capture_replay_baseline_at_cut(
747 &mut self,
748 root: &coven_protocol::store_commit::StoreRootRef,
749 cut: &coven_protocol::store_commit::CommitFrontier,
750 current_cut: &coven_protocol::store_commit::CommitFrontier,
751 snapshot_hash: crate::ObjectHash,
752 routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
753 ) -> Result<(Vec<u8>, Vec<crate::SettledStoreWrite>), DbError> {
754 let routing_key = if self.gates.has_scoped_graph() {
755 let encryption = routing_encryption.ok_or_else(|| {
756 DbError::Message(
757 "scoped replay baseline capture requires Store routing encryption".to_string(),
758 )
759 })?;
760 Some(
761 coven_protocol::circle::derive_row_routing_key(encryption, root.store_root_hash)
762 .map_err(DbError::from)?,
763 )
764 } else {
765 None
766 };
767 let folded = crate::StoreDatabase::settled_store_write_prefix_on(
768 crate::store::store_session::StoreRecords::new(self.conn, self.store_dir),
769 cut,
770 )?;
771 let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
772 let records =
773 crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir);
774 let current_replay = self
775 .verified_store_authority
776 .replay_projection_result_for_root_on(
777 records,
778 root,
779 self.blob_decls,
780 self.gates,
781 self.synced_tables,
782 routing_key.as_ref(),
783 current_cut,
784 )?;
785 if current_replay.materialized_frontier()? != *current_cut {
786 return Err(DbError::Message(
787 "replay retirement proof does not cover the current Store frontier".to_string(),
788 ));
789 }
790 let mut crossed_cut = false;
791 for reference in current_replay.applied_order() {
792 if cut.covers_commit(reference) {
793 if crossed_cut {
794 return Err(DbError::ReplayRetirementCutNotPrefix);
795 }
796 } else {
797 crossed_cut = true;
798 }
799 }
800 let replay = records.replay_projection_with_authority(
801 self.verified_store_authority,
802 root,
803 self.blob_decls,
804 self.gates,
805 self.synced_tables,
806 routing_key.as_ref(),
807 &std::collections::BTreeSet::new(),
808 Some(cut),
809 crate::ReplayJournal::Folded(&folded),
810 coven_protocol::membership::LocalStoreMembership::Current,
811 )?;
812 transaction.rollback().map_err(DbError::from)?;
813 let replay_frontier = replay.materialized_frontier()?;
814 if replay_frontier != *cut {
815 return Err(DbError::Message(
816 "retained replay baseline cut is not an exact Store frontier".to_string(),
817 ));
818 }
819 Ok((
820 replay.capture_replay_baseline(root, cut, snapshot_hash)?,
821 folded,
822 ))
823 }
824
825 #[allow(clippy::too_many_arguments)]
826 fn capture_circle_snapshot_at_cutoff(
827 &mut self,
828 root: &coven_protocol::store_commit::StoreRootRef,
829 temp_dir: &Path,
830 routing_encryption: &coven_keys::encryption::EncryptionService,
831 routing_key: &coven_protocol::circle::RowRoutingKey,
832 circle_id: coven_protocol::circle::CircleId,
833 cutoff: &coven_protocol::store_commit::CommitFrontier,
834 ) -> Result<CreatedSnapshot, DbError> {
835 let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
836 let replay =
837 crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
838 .replay_projection_with_authority(
839 self.verified_store_authority,
840 root,
841 self.blob_decls,
842 self.gates,
843 self.synced_tables,
844 Some(routing_key),
845 &std::collections::BTreeSet::new(),
846 Some(cutoff),
847 crate::ReplayJournal::Omit,
848 coven_protocol::membership::LocalStoreMembership::Current,
849 )?;
850 transaction.rollback().map_err(DbError::from)?;
851 let replay_frontier = replay.materialized_frontier()?;
852 if replay_frontier != *cutoff {
853 return Err(DbError::Message(
854 "Circle close cutoff is not an exact retained Store frontier".to_string(),
855 ));
856 }
857 SnapshotDatabaseImage::prepare_snapshot(temp_dir)
858 .and_then(|image| {
859 replay.capture_snapshot(
860 image,
861 root,
862 self.synced_tables,
863 Some(routing_encryption),
864 &coven_protocol::circle::Audience::Circle(circle_id),
865 )
866 })
867 .map_err(snapshot_image_db_error)
868 }
869
870 #[cfg(any(test, feature = "test-utils"))]
871 fn capture_snapshot_image_for_test(
872 &self,
873 root: &coven_protocol::store_commit::StoreRootRef,
874 temp_dir: &Path,
875 routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
876 audience: coven_protocol::circle::Audience,
877 ) -> Result<Vec<u8>, DbError> {
878 SnapshotDatabaseImage::prepare_snapshot(temp_dir)
879 .and_then(|image| {
880 crate::store::store_session::StoreRecords::new(self.conn, self.store_dir)
881 .capture_snapshot(
882 image,
883 root,
884 self.synced_tables,
885 routing_encryption,
886 &audience,
887 )
888 })
889 .and_then(|snapshot| snapshot.into_parts().0.read_and_discard())
890 .map_err(snapshot_image_db_error)
891 }
892}
893
894impl StoreDatabase {
895 pub async fn capture_store_snapshot_cut(
896 &self,
897 root: coven_protocol::store_commit::StoreRootRef,
898 temp_dir: PathBuf,
899 routing_encryption: Option<coven_keys::encryption::EncryptionService>,
900 ) -> Result<
901 (
902 CreatedSnapshot,
903 coven_protocol::store_commit::CommitFrontier,
904 ),
905 DbError,
906 > {
907 self.call_store(move |session| {
908 session.capture_snapshot_cut(
909 &root,
910 &temp_dir,
911 routing_encryption.as_ref(),
912 coven_protocol::circle::Audience::Store,
913 )
914 })
915 .await
916 }
917
918 pub async fn capture_circle_snapshot_cut(
919 &self,
920 root: coven_protocol::store_commit::StoreRootRef,
921 temp_dir: PathBuf,
922 routing_encryption: coven_keys::encryption::EncryptionService,
923 circle_id: coven_protocol::circle::CircleId,
924 ) -> Result<
925 (
926 CreatedSnapshot,
927 coven_protocol::store_commit::CommitFrontier,
928 ),
929 DbError,
930 > {
931 self.call_store(move |session| {
932 session.capture_snapshot_cut(
933 &root,
934 &temp_dir,
935 Some(&routing_encryption),
936 coven_protocol::circle::Audience::Circle(circle_id),
937 )
938 })
939 .await
940 }
941
942 #[allow(clippy::too_many_arguments)]
943 pub async fn capture_circle_snapshot_at_cutoff(
944 &self,
945 root: coven_protocol::store_commit::StoreRootRef,
946 temp_dir: PathBuf,
947 routing_encryption: coven_keys::encryption::EncryptionService,
948 routing_key: coven_protocol::circle::RowRoutingKey,
949 circle_id: coven_protocol::circle::CircleId,
950 cutoff: coven_protocol::store_commit::CommitFrontier,
951 ) -> Result<CreatedSnapshot, DbError> {
952 self.call_store(move |session| {
953 session.capture_circle_snapshot_at_cutoff(
954 &root,
955 &temp_dir,
956 &routing_encryption,
957 &routing_key,
958 circle_id,
959 &cutoff,
960 )
961 })
962 .await
963 }
964
965 pub async fn verify_circle_bootstrap_image(
966 &self,
967 image: Vec<u8>,
968 reference: coven_protocol::circle::CircleBootstrapRef,
969 circle_id: coven_protocol::circle::CircleId,
970 routing_key: Option<coven_protocol::circle::RowRoutingKey>,
971 ) -> Result<Vec<u8>, SnapshotImageError> {
972 self.call_store(move |session| {
973 let verification = verify_circle_bootstrap_image(
974 &image,
975 &reference,
976 circle_id,
977 session.synced_tables,
978 routing_key.as_ref(),
979 );
980 Ok(verification.map(|()| image))
981 })
982 .await
983 .map_err(SnapshotImageError::from)?
984 }
985
986 #[cfg(any(test, feature = "test-utils"))]
987 pub async fn capture_snapshot_image_for_test(
988 &self,
989 root: coven_protocol::store_commit::StoreRootRef,
990 temp_dir: PathBuf,
991 routing_encryption: Option<coven_keys::encryption::EncryptionService>,
992 ) -> Result<Vec<u8>, DbError> {
993 self.call_store(move |session| {
994 session.capture_snapshot_image_for_test(
995 &root,
996 &temp_dir,
997 routing_encryption.as_ref(),
998 coven_protocol::circle::Audience::Store,
999 )
1000 })
1001 .await
1002 }
1003
1004 #[cfg(any(test, feature = "test-utils"))]
1005 pub async fn capture_circle_snapshot_image_for_test(
1006 &self,
1007 root: coven_protocol::store_commit::StoreRootRef,
1008 temp_dir: PathBuf,
1009 routing_encryption: coven_keys::encryption::EncryptionService,
1010 circle_id: coven_protocol::circle::CircleId,
1011 ) -> Result<Vec<u8>, DbError> {
1012 self.call_store(move |session| {
1013 session.capture_snapshot_image_for_test(
1014 &root,
1015 &temp_dir,
1016 Some(&routing_encryption),
1017 coven_protocol::circle::Audience::Circle(circle_id),
1018 )
1019 })
1020 .await
1021 }
1022}
1023
1024pub(super) fn snapshot_image_db_error(error: SnapshotImageError) -> DbError {
1025 DbError::from(error)
1026}
1027
1028fn require_no_unpublished_store_writes(connection: &Connection) -> Result<(), DbError> {
1029 let pending: i64 = connection
1030 .query_row(
1031 "SELECT EXISTS(
1032 SELECT 1 FROM store_writes
1033 WHERE status != '\"local_only\"'
1034 AND json_extract(status, '$.published') IS NULL
1035 )",
1036 [],
1037 |row| row.get(0),
1038 )
1039 .map_err(DbError::from)?;
1040 if pending != 0 {
1041 return Err(DbError::Message(
1042 "snapshot cut refused while unpublished Store writes exist".to_string(),
1043 ));
1044 }
1045 Ok(())
1046}
1047
1048const SNAPSHOT_PRESERVED_NON_SYNCED_TABLES: &[&str] = &[
1049 "_coven_audience",
1050 "_coven_row_routes",
1051 "remote_objects",
1052 "blob_locators",
1053 "row_blob_locators",
1054 "store_device_registration_activations",
1055 "store_device_state_snapshots",
1056 "store_device_states",
1057 "store_author_exclusion_activations",
1058 "circle_control_activations",
1059 "circle_access_cache",
1060 "circle_bootstrap_coverage",
1061 "circle_current_state",
1062 "retained_merge_materializations",
1063 "retained_replay_objects",
1064];
1065
1066const CIRCLE_IMAGE_PRESERVED_NON_SYNCED_TABLES: &[&str] = &[
1067 "_coven_audience",
1068 "_coven_row_routes",
1069 "remote_objects",
1070 "blob_locators",
1071 "row_blob_locators",
1072 "retained_merge_materializations",
1073 "retained_replay_objects",
1074];
1075
1076fn scope_authenticated_blob_graph(
1077 connection: &Connection,
1078 synced: &[SyncedTable],
1079) -> Result<(), SnapshotImageError> {
1080 connection
1081 .execute_batch(
1082 "CREATE TEMP TABLE snapshot_live_blob_bindings (
1083 table_name TEXT NOT NULL,
1084 row_id TEXT NOT NULL,
1085 column_name TEXT NOT NULL,
1086 row_stamp TEXT NOT NULL,
1087 PRIMARY KEY (table_name, row_id, column_name, row_stamp)
1088 ) STRICT;",
1089 )
1090 .map_err(|error| SnapshotImageError::ProjectionSqlite {
1091 operation: "create blob scope".to_string(),
1092 source: error,
1093 })?;
1094 for table in synced {
1095 let Some(declaration) = table.blob() else {
1096 continue;
1097 };
1098 connection
1099 .execute(
1100 &format!(
1101 "INSERT INTO snapshot_live_blob_bindings
1102 (table_name, row_id, column_name, row_stamp)
1103 SELECT ?1, id, ?2, _updated_at FROM {}
1104 WHERE {} IS NOT NULL",
1105 crate::quote_ident(table.name()),
1106 crate::quote_ident(&declaration.id_column),
1107 ),
1108 rusqlite::params![table.name(), &declaration.id_column],
1109 )
1110 .map_err(|error| SnapshotImageError::ProjectionSqlite {
1111 operation: format!("collect live blob bindings for {:?}", table.name()),
1112 source: error,
1113 })?;
1114 }
1115 connection
1118 .execute_batch(
1119 "DELETE FROM row_blob_locators
1120 WHERE NOT EXISTS (
1121 SELECT 1 FROM snapshot_live_blob_bindings AS live
1122 WHERE live.table_name = row_blob_locators.table_name
1123 AND live.row_id = row_blob_locators.row_id
1124 AND live.column_name = row_blob_locators.column_name
1125 AND live.row_stamp = row_blob_locators.row_stamp
1126 );
1127 DELETE FROM blob_locators
1128 WHERE NOT EXISTS (
1129 SELECT 1 FROM row_blob_locators AS binding
1130 WHERE binding.remote_object_id = blob_locators.remote_object_id
1131 );
1132 DELETE FROM remote_objects
1133 WHERE NOT EXISTS (
1134 SELECT 1 FROM blob_locators AS locator
1135 WHERE locator.remote_object_id = remote_objects.object_id
1136 ) AND NOT EXISTS (
1137 SELECT 1 FROM retained_replay_objects AS retained
1138 WHERE retained.object_id = remote_objects.object_id
1139 );
1140 DROP TABLE snapshot_live_blob_bindings;",
1141 )
1142 .map_err(|error| SnapshotImageError::ProjectionSqlite {
1143 operation: "scope blob ownership graph".to_string(),
1144 source: error,
1145 })?;
1146 Ok(())
1147}
1148
1149pub(super) fn verify_circle_bootstrap_image(
1150 image: &[u8],
1151 reference: &coven_protocol::circle::CircleBootstrapRef,
1152 circle_id: coven_protocol::circle::CircleId,
1153 tables: &[SyncedTable],
1154 routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
1155) -> Result<(), SnapshotImageError> {
1156 if coven_protocol::store_commit::ObjectHash::digest(image) != reference.image.image_hash {
1157 return Err(SnapshotImageError::Projection(
1158 "Circle bootstrap image differs from its signed hash".to_string(),
1159 ));
1160 }
1161 let mut connection = Connection::open_in_memory()
1162 .map_err(DbError::from)
1163 .map_err(SnapshotImageError::from)?;
1164 crate::connection_io::deserialize_database_image_into(&mut connection, image)
1165 .map_err(SnapshotImageError::from)?;
1166 verify_circle_bootstrap_connection(&connection, reference, circle_id, tables, routing_key)
1167}
1168
1169pub(crate) fn verify_circle_bootstrap_connection(
1170 connection: &Connection,
1171 reference: &coven_protocol::circle::CircleBootstrapRef,
1172 circle_id: coven_protocol::circle::CircleId,
1173 tables: &[SyncedTable],
1174 routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
1175) -> Result<(), SnapshotImageError> {
1176 connection
1177 .pragma_update(None, "foreign_keys", "ON")
1178 .map_err(SnapshotImageError::from)?;
1179 let schema_version: u32 = connection
1180 .pragma_query_value(None, "user_version", |row| row.get(0))
1181 .map_err(SnapshotImageError::from)?;
1182 if schema_version != reference.schema_version {
1183 return Err(SnapshotImageError::Projection(format!(
1184 "Circle bootstrap schema is {schema_version}, expected {}",
1185 reference.schema_version
1186 )));
1187 }
1188 let routing_contract = crate::SyncRoutingContract::from_connection(connection, tables)
1189 .map_err(SnapshotImageError::from)?;
1190 if routing_contract.hash() != reference.sync_routing_hash {
1191 return Err(SnapshotImageError::Projection(
1192 "Circle bootstrap routing contract differs from its signed hash".to_string(),
1193 ));
1194 }
1195 let gates = crate::Gates::from_tables(connection, tables).map_err(SnapshotImageError::from)?;
1196 if gates.has_scoped_graph() {
1197 let routing_key = routing_key.ok_or_else(|| {
1198 SnapshotImageError::Projection(
1199 "scoped Circle bootstrap verification requires Store routing authentication"
1200 .to_string(),
1201 )
1202 })?;
1203 crate::validate_snapshot_routing_state(
1204 connection,
1205 &gates,
1206 routing_key,
1207 &coven_protocol::circle::Audience::Circle(circle_id),
1208 )
1209 .map_err(SnapshotImageError::from)?;
1210 }
1211 let declarations =
1212 crate::BlobDecls::from_tables(connection, tables).map_err(SnapshotImageError::from)?;
1213 let rows = declarations
1214 .publication_blobs_in_db(connection)
1215 .map_err(SnapshotImageError::from)?;
1216 if rows.len() != reference.blobs.len() {
1217 return Err(SnapshotImageError::Projection(
1218 "Circle bootstrap blob closure does not exactly cover its image rows".to_string(),
1219 ));
1220 }
1221 for row in &rows {
1222 let mut matching = reference.blobs.iter().filter(|binding| {
1223 row.table == binding.table()
1224 && row.row_id == binding.row_id()
1225 && row.row_stamp == binding.row_stamp()
1226 && row.column == binding.column()
1227 });
1228 let binding = matching.next().ok_or_else(|| {
1229 SnapshotImageError::Projection(
1230 "Circle bootstrap image row has no exact signed blob binding".to_string(),
1231 )
1232 })?;
1233 if matching.next().is_some()
1234 || &row.blob != binding.blob()
1235 || row.plaintext_size != binding.plaintext_size()
1236 || row.plaintext_hash != binding.plaintext_hash().to_string()
1237 || !matches!(
1238 binding.authority(),
1239 coven_protocol::blob::RowBlobAuthority::Remote(
1240 coven_protocol::audience_package::PackageAudience::Circle {
1241 circle_id: binding_circle,
1242 ..
1243 }
1244 ) if *binding_circle == circle_id
1245 )
1246 || binding.stored().is_none()
1247 {
1248 return Err(SnapshotImageError::Projection(
1249 "Circle bootstrap blob closure differs from an exact image row".to_string(),
1250 ));
1251 }
1252 }
1253 for table in crate::user_table_names(connection).map_err(SnapshotImageError::from)? {
1254 if tables.iter().any(|synced| synced.name() == table)
1255 || matches!(table.as_str(), "_coven_audience" | "_coven_row_routes")
1256 {
1257 continue;
1258 }
1259 let count: i64 = connection
1260 .query_row(
1261 &format!("SELECT COUNT(*) FROM {}", crate::quote_ident(&table)),
1262 [],
1263 |row| row.get(0),
1264 )
1265 .map_err(SnapshotImageError::from)?;
1266 if count != 0 {
1267 return Err(SnapshotImageError::Projection(format!(
1268 "Circle bootstrap retains non-projection table {table:?}"
1269 )));
1270 }
1271 }
1272 Ok(())
1273}
1274
1275#[cfg(test)]
1276#[path = "snapshot_image_tests.rs"]
1277mod tests;