Skip to main content

coven_database/store/store_session/
snapshot_publication.rs

1use std::collections::BTreeSet;
2
3use crate::*;
4use coven_protocol::store_commit::{
5    snapshot_image_semantic_prefix, SnapshotMeta, StoreSnapshotRef,
6};
7
8use super::*;
9
10impl StoreSession<'_> {
11    fn outbound_snapshot_publication(
12        &mut self,
13    ) -> Result<Option<DurableSnapshotPublication>, DbError> {
14        let authority = self.local_store_authority()?;
15        load_outbound_store_snapshot_on(self.conn, self.store_dir, &authority)
16    }
17
18    fn stage_snapshot_publication(
19        &mut self,
20        stage: StoreSnapshotPublicationStage,
21        meta: SnapshotMeta,
22        meta_prepared: PreparedExactObject,
23        publication: coven_protocol::prepared_commit::PreparedStorePublication,
24        rollup_bytes: Vec<u8>,
25        rollup_prepared: PreparedExactObject,
26        image: SnapshotDatabaseImage,
27        image_prepared: PreparedExactObject,
28        blobs: Vec<PreparedSnapshotBlob>,
29    ) -> Result<StoreSnapshotRef, DbError> {
30        let authority = self.local_store_authority()?;
31        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
32        let image_facts =
33            crate::payload_store::write_payload_file_blocking(&tx, self.store_dir, image.path())
34                .map_err(|source| SnapshotImageError::ProjectionPayloadStore {
35                    operation: "spool Store snapshot image".to_string(),
36                    source,
37                });
38        let (image_hash, _) = image.finish(image_facts).map_err(snapshot_image_db_error)?;
39        let image_prepared_hash = crate::payload_store::write_payload_blocking(
40            &tx,
41            self.store_dir,
42            image_prepared.stored_bytes(),
43        )
44        .map_err(|error| DbError::context("spool prepared Store snapshot image", error))?;
45        let image_prepared_size = image_prepared.stored_bytes().len() as u64;
46        let registration_ref = authority.reference();
47        let registration = authority.value();
48        validate_snapshot_author(&meta.author_registration, registration_ref, "Store")?;
49        validate_snapshot_image(
50            &meta.image,
51            &image_prepared,
52            image_hash,
53            image_prepared_hash,
54            image_prepared_size,
55            format!(
56                "{}.db",
57                snapshot_image_semantic_prefix(
58                    meta_prepared.reference().slot(),
59                    meta.image.image_hash,
60                )
61            ),
62            "Store",
63        )?;
64        let reference = StoreSnapshotRef {
65            snapshot_hash: meta.snapshot_hash(),
66            object: meta_prepared.reference().clone(),
67        };
68        SnapshotMeta::parse_at(
69            &meta.to_bytes(),
70            registration.store_root.store_root_hash,
71            &reference,
72            registration,
73        )
74        .map_err(|error| DbError::context("verify staged Store snapshot metadata", error))?;
75        publication
76            .validate_snapshot_shape(&meta, &reference)
77            .map_err(|error| DbError::context("verify staged Store snapshot publication", error))?;
78        coven_protocol::store_commit::MembershipRollup::parse_at(
79            &rollup_bytes,
80            registration.store_root.store_root_hash,
81            &meta.membership_rollup,
82            registration,
83        )
84        .map_err(|error| DbError::context("verify staged membership rollup", error))?;
85        if rollup_prepared.reference() != &meta.membership_rollup.object {
86            return Err(DbError::Message(
87                "staged membership rollup differs from the snapshot that names it".to_string(),
88            ));
89        }
90        // Spooled beside the image rather than carried in the row: a rollup
91        // holds every membership object the Store has, which is KB-class and
92        // belongs in the payload store.
93        let rollup_hash =
94            crate::payload_store::write_payload_blocking(&tx, self.store_dir, &rollup_bytes)
95                .map_err(|error| DbError::context("spool membership rollup", error))?;
96        let rollup_prepared_hash = crate::payload_store::write_payload_blocking(
97            &tx,
98            self.store_dir,
99            rollup_prepared.stored_bytes(),
100        )
101        .map_err(|error| DbError::context("spool prepared membership rollup", error))?;
102        if rollup_hash != meta.membership_rollup.rollup_hash {
103            return Err(DbError::Message(
104                "staged membership rollup bytes differ from the hash the snapshot names"
105                    .to_string(),
106            ));
107        }
108        let snapshot_owner = coven_protocol::remote_object::SnapshotObjectOwner::Store {
109            metadata_slot: reference.object.slot().clone(),
110        };
111        // The publication names this immutable image. Current rows may already
112        // contain unpublished replacements, deletions, or audience changes.
113        let image_bytes =
114            crate::payload_store::read_verified_payload_blocking(&tx, self.store_dir, image_hash)
115                .map_err(|error| DbError::context("read staged Store snapshot image", error))?;
116        let mut captured = Connection::open_in_memory()?;
117        crate::connection_io::deserialize_database_image_into(&mut captured, &image_bytes)?;
118        let captured_gates = Gates::from_tables(&captured, self.synced_tables)?;
119        validate_snapshot_blob_plans_on(
120            &captured,
121            &captured_gates,
122            self.synced_tables,
123            &snapshot_owner,
124            &blobs,
125        )?;
126        drop(captured);
127        drop(image_bytes);
128        let mut active_publication = ActiveStorePublication::snapshot(publication)?;
129        match stage {
130            StoreSnapshotPublicationStage::Initial => {
131                match super::active_store_publication::claim_active_store_publication_on(
132                    &tx,
133                    &active_publication,
134                )? {
135                    super::active_store_publication::ActiveStorePublicationClaim::Acquired => {}
136                    super::active_store_publication::ActiveStorePublicationClaim::AlreadyOwned => {
137                        return Err(DbError::Message(
138                            "Store snapshot already owns publication before its journal"
139                                .to_string(),
140                        ));
141                    }
142                    super::active_store_publication::ActiveStorePublicationClaim::Occupied(
143                        owner,
144                    ) => {
145                        return Err(DbError::Message(format!(
146                            "another local Store operation owns publication: {owner:?}"
147                        )));
148                    }
149                }
150            }
151            StoreSnapshotPublicationStage::Replacing { previous, accepted } => {
152                let old = load_outbound_store_snapshot_on(&tx, self.store_dir, &authority)?
153                    .ok_or_else(|| {
154                        DbError::Message("replacement snapshot has no pending candidate".into())
155                    })?;
156                if old.reference != previous {
157                    return Err(DbError::Message(
158                        "snapshot candidate changed before replacement".into(),
159                    ));
160                }
161                let active =
162                    super::active_store_publication::load_active_store_publication_on(&tx)?
163                        .ok_or_else(|| {
164                            DbError::Message("snapshot replacement has no active owner".into())
165                        })?;
166                if active.owner() != &ActiveStorePublicationOwner::Snapshot
167                    || active.attempt()? != &old.publication
168                    || !active.retired_snapshot_objects().is_empty()
169                {
170                    return Err(DbError::Message(
171                        "snapshot replacement differs from its active owner".into(),
172                    ));
173                }
174                let observed =
175                    super::observed_store_publication::load_store_current_publication_on(&tx)?;
176                if observed.record() != accepted.interval().current()
177                    || observed.observed_version() != accepted.current_version()
178                    || active_publication.attempt()?.previous != *observed.record()
179                    || Some(&active_publication.attempt()?.previous_version)
180                        != observed.observed_version()
181                {
182                    return Err(DbError::Message(
183                        "snapshot replacement does not extend its installed winner".into(),
184                    ));
185                }
186                let old_entry = old.publication.reference()?;
187                let winner = accepted
188                    .interval()
189                    .entries()
190                    .iter()
191                    .find(|entry| entry.reference().position == old_entry.position)
192                    .ok_or_else(|| {
193                        DbError::Message(
194                            "snapshot replacement lacks its settled exact position".into(),
195                        )
196                    })?;
197                if winner.entry().previous_state_hash != old.publication.previous.state_hash() {
198                    return Err(DbError::Message(
199                        "snapshot winner names another exact predecessor".into(),
200                    ));
201                }
202                if winner.reference() == &old_entry || accepted.interval().entries().iter().any(|entry|
203                    matches!(&entry.entry().payload, coven_protocol::store_commit::StorePublicationPayload::Snapshot(snapshot) if snapshot == &old.reference))
204                {
205                    return Err(DbError::Message("an accepted snapshot cannot be replaced as an unaccepted candidate".into()));
206                }
207                let retained = BTreeSet::from([
208                    reference.object.clone(),
209                    meta.image.object.clone(),
210                    meta.membership_rollup.object.clone(),
211                    active_publication.attempt()?.entry_object.clone(),
212                ]);
213                let cleanup = snapshot_candidate_cleanup_on(&tx, &old, &retained)?;
214                active_publication.retain_snapshot_cleanup(cleanup)?;
215                super::active_store_publication::update_active_store_publication_on(
216                    &tx,
217                    &active,
218                    &active_publication,
219                )?;
220                tx.execute(
221                    "DELETE FROM outbound_store_snapshot WHERE singleton = 1",
222                    [],
223                )?;
224            }
225        }
226        tx.execute(
227            "INSERT INTO outbound_store_snapshot \
228             (singleton, snapshot_ref, meta_prepared, meta_bytes, blobs) \
229             VALUES (1, ?1, ?2, ?3, ?4)",
230            rusqlite::params![
231                serde_json::to_string(&reference).map_err(|error| {
232                    DbError::context("serialize exact Store snapshot ref", error)
233                })?,
234                serde_json::to_string(&meta_prepared).map_err(|error| {
235                    DbError::context("serialize prepared Store snapshot metadata", error)
236                })?,
237                meta.to_bytes(),
238                serde_json::to_string(&blobs).map_err(|error| {
239                    DbError::context("serialize prepared Store snapshot blobs", error)
240                })?,
241            ],
242        )
243        .map_err(DbError::from)?;
244        crate::payload_store::set_payload_owner_claims_on(
245            &tx,
246            crate::payload_store::OUTBOUND_STORE_SNAPSHOT_OWNER_KEY,
247            &BTreeSet::from([
248                image_hash,
249                image_prepared_hash,
250                rollup_hash,
251                rollup_prepared_hash,
252            ]),
253        )?;
254        tx.commit().map_err(DbError::from)?;
255        Ok(reference)
256    }
257
258    fn supersede_snapshot_publication(
259        &mut self,
260        previous: StoreSnapshotRef,
261        snapshot: PublishedStoreSnapshot,
262        accepted: AcceptedStorePublicationInterval,
263    ) -> Result<(), DbError> {
264        let baseline = self.installed_replay_baseline()?;
265        if baseline.snapshot() != Some(&snapshot) {
266            return Err(DbError::Message(
267                "superseding snapshot is not the installed verified baseline".into(),
268            ));
269        }
270        let authority = self.local_store_authority()?;
271        let tx = self.conn.unchecked_transaction()?;
272        let old = load_outbound_store_snapshot_on(&tx, self.store_dir, &authority)?
273            .ok_or_else(|| DbError::Message("superseded snapshot has no pending request".into()))?;
274        let active = super::active_store_publication::load_active_store_publication_on(&tx)?
275            .ok_or_else(|| DbError::Message("superseded snapshot has no active owner".into()))?;
276        if old.reference != previous || active.attempt()? != &old.publication {
277            return Err(DbError::Message(
278                "snapshot request changed before supersession".into(),
279            ));
280        }
281        let observed = super::observed_store_publication::load_store_current_publication_on(&tx)?;
282        if observed.record() != accepted.interval().current()
283            || observed.observed_version() != accepted.current_version()
284        {
285            return Err(DbError::Message(
286                "superseding snapshot has another installed publication interval".into(),
287            ));
288        }
289        let entry = accepted.interval().entries().iter().find(|entry|
290            matches!(&entry.entry().payload, coven_protocol::store_commit::StorePublicationPayload::Snapshot(reference) if reference == &snapshot.reference)
291        ).ok_or_else(|| DbError::Message("superseding snapshot is absent from the accepted interval".into()))?;
292        if entry.reference().position <= old.publication.reference()?.position
293            || entry.entry().previous_state_hash
294                != snapshot.meta.publication_predecessor.state_hash()
295            || !snapshot.meta.coverage.covers(&old.meta.value.coverage)
296        {
297            return Err(DbError::Message(
298                "accepted snapshot does not supersede the requested checkpoint".into(),
299            ));
300        }
301        let retained = BTreeSet::from([
302            snapshot.reference.object.clone(),
303            snapshot.meta.image.object.clone(),
304            snapshot.meta.membership_rollup.object.clone(),
305            entry.reference().object.clone(),
306        ]);
307        let cleanup = snapshot_candidate_cleanup_on(&tx, &old, &retained)?;
308        let superseded = active.supersede_snapshot(snapshot, cleanup)?;
309        super::active_store_publication::update_active_store_publication_on(
310            &tx,
311            &active,
312            &superseded,
313        )?;
314        tx.commit()?;
315        Ok(())
316    }
317
318    fn complete_superseded_snapshot_publication(
319        &mut self,
320        expected: ActiveStorePublication,
321    ) -> Result<SnapshotMeta, DbError> {
322        let snapshot = expected.superseding_snapshot().ok_or_else(|| {
323            DbError::Message("snapshot request has no verified superseding checkpoint".into())
324        })?;
325        let authority = self.local_store_authority()?;
326        let tx = self.conn.unchecked_transaction()?;
327        let pending = load_outbound_store_snapshot_on(&tx, self.store_dir, &authority)?
328            .ok_or_else(|| DbError::Message("superseded snapshot request is absent".into()))?;
329        if expected.attempt()? != &pending.publication {
330            return Err(DbError::Message(
331                "superseded snapshot differs from its original request".into(),
332            ));
333        }
334        super::active_store_publication::clear_active_store_publication_on(&tx, &expected)?;
335        tx.execute(
336            "DELETE FROM outbound_store_snapshot WHERE singleton = 1",
337            [],
338        )?;
339        crate::payload_store::release_payload_owner_on(
340            &tx,
341            crate::payload_store::OUTBOUND_STORE_SNAPSHOT_OWNER_KEY,
342        )?;
343        tx.commit()?;
344        Ok(snapshot.meta.clone())
345    }
346
347    fn complete_snapshot_candidate_cleanup(
348        &self,
349        expected: ActiveStorePublication,
350    ) -> Result<(), DbError> {
351        let mut replacement = expected.clone();
352        replacement.complete_snapshot_cleanup()?;
353        super::active_store_publication::update_active_store_publication_on(
354            self.conn,
355            &expected,
356            &replacement,
357        )
358    }
359
360    fn latest_local_store_snapshot(&mut self) -> Result<Option<PublishedStoreSnapshot>, DbError> {
361        let root = self.required_root_authority()?;
362        StoreRecords::new(self.conn, self.store_dir)
363            .published_store_snapshot(&root, self.verified_store_authority)
364    }
365
366    #[cfg(any(test, feature = "test-utils"))]
367    fn local_store_snapshots(&mut self) -> Result<Vec<PublishedStoreSnapshot>, DbError> {
368        let root = self.required_root_authority()?;
369        StoreRecords::new(self.conn, self.store_dir)
370            .published_store_snapshots(&root, self.verified_store_authority)
371    }
372
373    fn complete_snapshot_publication(
374        &mut self,
375        accepted: crate::AcceptedStorePublicationInterval,
376    ) -> Result<SnapshotMeta, DbError> {
377        let authority = self.local_store_authority()?;
378        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
379        let outbound = load_outbound_store_snapshot_on(&tx, self.store_dir, &authority)?
380            .ok_or_else(|| DbError::Message("outbound Store snapshot is absent".to_string()))?;
381        let accepted_entry = accepted
382            .interval()
383            .entries()
384            .iter()
385            .find(|entry| {
386                matches!(
387                    &entry.entry().payload,
388                    coven_protocol::store_commit::StorePublicationPayload::Snapshot(reference)
389                        if reference == &outbound.reference
390                )
391            })
392            .ok_or_else(|| {
393                DbError::Message(
394                    "accepted Store publication does not contain the prepared snapshot".to_string(),
395                )
396            })?;
397        if accepted.interval().previous() != &*outbound.publication.previous
398            || accepted_entry.entry() != &outbound.publication.entry
399            || accepted_entry.reference().object != outbound.publication.entry_object
400        {
401            return Err(DbError::Message(
402                "accepted Store snapshot differs from the prepared publication".to_string(),
403            ));
404        }
405        let superseded = StoreTransaction::new(&tx, self.store_dir)
406            .retire_snapshot_artifact_ownership(
407                self.verified_store_authority,
408                &authority.value().store_root,
409                &coven_protocol::store_commit::AcceptedStoreSnapshotRef {
410                    snapshot: outbound.reference.clone(),
411                    publication: accepted_entry.reference().clone(),
412                },
413                &outbound.meta.value,
414            )?;
415        install_snapshot_blob_plans_on(&tx, &outbound.blobs)?;
416        let snapshot_owner = coven_protocol::remote_object::SnapshotObjectOwner::Store {
417            metadata_slot: outbound.reference.object.slot().clone(),
418        };
419        persist_snapshot_image_on(
420            &tx,
421            self.store_dir,
422            &outbound.meta.value.image,
423            snapshot_owner.clone(),
424            "Store snapshot image",
425        )?;
426        crate::snapshot_objects::persist_membership_rollup_on(
427            &tx,
428            self.store_dir,
429            &outbound.meta.value.membership_rollup,
430            snapshot_owner,
431            "Store membership rollup",
432        )?;
433        StoreTransaction::new(&tx, self.store_dir)
434            .retire_store_blob_snapshot_ownership(outbound.reference.object.slot(), &superseded)?;
435        let deleted = tx
436            .execute(
437                "DELETE FROM outbound_store_snapshot \
438                 WHERE singleton = 1 AND snapshot_ref = ?1",
439                [serde_json::to_string(&outbound.reference).map_err(|error| {
440                    DbError::context("serialize accepted Store snapshot ref", error)
441                })?],
442            )
443            .map_err(DbError::from)?;
444        if deleted != 1 {
445            return Err(DbError::Message(
446                "outbound snapshot ownership row is absent or changed".to_string(),
447            ));
448        }
449        crate::payload_store::release_payload_owner_on(
450            &tx,
451            crate::payload_store::OUTBOUND_STORE_SNAPSHOT_OWNER_KEY,
452        )?;
453        let accepted_position =
454            i64::try_from(accepted_entry.reference().position.get()).map_err(|_| {
455                DbError::Message("Store snapshot position exceeds SQLite integer".into())
456            })?;
457        tx.execute(
458            "INSERT INTO published_store_snapshot \
459             (publication_position, snapshot_ref, meta_bytes) VALUES (?1, ?2, ?3)",
460            rusqlite::params![
461                accepted_position,
462                serde_json::to_string(&outbound.reference).map_err(|error| {
463                    DbError::context("serialize published Store snapshot ref", error)
464                })?,
465                outbound.meta.bytes,
466            ],
467        )
468        .map_err(DbError::from)?;
469        let expected = super::observed_store_publication::load_store_current_publication_on(&tx)?;
470        if expected.record() != accepted.interval().current() {
471            super::observed_store_publication::install_store_publication_interval_on(
472                &tx, &expected, &accepted,
473            )?;
474        } else if expected.observed_version() != accepted.current_version() {
475            return Err(DbError::Message(
476                "accepted Store snapshot revision differs from the installed boundary".into(),
477            ));
478        }
479        let active_publication = ActiveStorePublication::snapshot(outbound.publication.clone())?;
480        super::active_store_publication::clear_active_store_publication_on(
481            &tx,
482            &active_publication,
483        )?;
484        tx.commit().map_err(DbError::from)?;
485        Ok(outbound.meta.value)
486    }
487}
488
489impl StoreDatabase {
490    pub async fn outbound_snapshot_publication(
491        &self,
492    ) -> Result<Option<DurableSnapshotPublication>, DbError> {
493        self.call_store(|session| session.outbound_snapshot_publication())
494            .await
495    }
496
497    #[allow(clippy::too_many_arguments)]
498    pub async fn stage_snapshot_publication(
499        &self,
500        stage: StoreSnapshotPublicationStage,
501        meta: SnapshotMeta,
502        meta_prepared: PreparedExactObject,
503        publication: coven_protocol::prepared_commit::PreparedStorePublication,
504        rollup_bytes: Vec<u8>,
505        rollup_prepared: PreparedExactObject,
506        image: SnapshotDatabaseImage,
507        image_prepared: PreparedExactObject,
508        blobs: Vec<PreparedSnapshotBlob>,
509    ) -> Result<StoreSnapshotRef, DbError> {
510        self.call_store(move |session| {
511            session.stage_snapshot_publication(
512                stage,
513                meta,
514                meta_prepared,
515                publication,
516                rollup_bytes,
517                rollup_prepared,
518                image,
519                image_prepared,
520                blobs,
521            )
522        })
523        .await
524    }
525
526    pub async fn supersede_snapshot_publication(
527        &self,
528        previous: StoreSnapshotRef,
529        snapshot: PublishedStoreSnapshot,
530        accepted: AcceptedStorePublicationInterval,
531    ) -> Result<(), DbError> {
532        self.call_store(move |session| {
533            session.supersede_snapshot_publication(previous, snapshot, accepted)
534        })
535        .await
536    }
537
538    pub async fn complete_superseded_snapshot_publication(
539        &self,
540        expected: ActiveStorePublication,
541    ) -> Result<SnapshotMeta, DbError> {
542        self.call_store(move |session| session.complete_superseded_snapshot_publication(expected))
543            .await
544    }
545
546    pub async fn complete_snapshot_candidate_cleanup(
547        &self,
548        expected: ActiveStorePublication,
549    ) -> Result<(), DbError> {
550        self.call_store(move |session| session.complete_snapshot_candidate_cleanup(expected))
551            .await
552    }
553
554    pub async fn latest_local_store_snapshot(
555        &self,
556    ) -> Result<Option<PublishedStoreSnapshot>, DbError> {
557        self.call_store(|session| session.latest_local_store_snapshot())
558            .await
559    }
560
561    #[cfg(any(test, feature = "test-utils"))]
562    pub async fn local_store_snapshots(&self) -> Result<Vec<PublishedStoreSnapshot>, DbError> {
563        self.call_store(|session| session.local_store_snapshots())
564            .await
565    }
566
567    pub async fn complete_snapshot_publication(
568        &self,
569        accepted: crate::AcceptedStorePublicationInterval,
570    ) -> Result<SnapshotMeta, DbError> {
571        self.call_store(move |session| session.complete_snapshot_publication(accepted))
572            .await
573    }
574}
575
576/// A pending snapshot has no installed image/rollup lease. Records already in
577/// the accepted object graph retain their own retirement authority; this owner
578/// retires only its otherwise unowned exact candidate objects.
579fn snapshot_candidate_cleanup_on(
580    connection: &Connection,
581    old: &DurableSnapshotPublication,
582    retained: &BTreeSet<ExactObjectRef>,
583) -> Result<Vec<ExactObjectRef>, DbError> {
584    let mut cleanup = Vec::new();
585    for object in BTreeSet::from([
586        old.reference.object.clone(),
587        old.meta.value.image.object.clone(),
588        old.meta.value.membership_rollup.object.clone(),
589        old.publication.entry_object.clone(),
590    ]) {
591        if retained.contains(&object) {
592            continue;
593        }
594        let object_id = coven_protocol::remote_object::remote_object_id(&object);
595        let owned: bool = connection.query_row(
596            "SELECT EXISTS(SELECT 1 FROM remote_objects WHERE object_id = ?1)",
597            [object_id.to_string()],
598            |row| row.get(0),
599        )?;
600        if owned {
601            crate::load_remote_object_on(connection, object_id)?;
602        } else {
603            cleanup.push(object);
604        }
605    }
606    Ok(cleanup)
607}