Skip to main content

coven_database/store/store_session/
pending_publication.rs

1use super::{publication_state::PreparedStoreWriteState, StoreDatabase, StoreSession};
2use crate::{
3    load_prepared_audience_objects_on, DbError, ExactProtocolObject, PreparedStoreWriteCommit,
4};
5use coven_protocol::membership::AuthorStreamId;
6use coven_protocol::store_commit::{
7    CommitFrontier, StoreBatchCommit, StoreBatchCommitRef, StoreCommitCoord,
8    StoreDeviceRegistrationRef, VerifiedStoreBatchCommit,
9};
10use coven_protocol::write::WriteId;
11use rusqlite::OptionalExtension;
12use std::collections::BTreeMap;
13
14/// One reading of the local ledger: the author's own latest position, the
15/// materialized frontier it belongs to, and this device's turn to author the
16/// commit that extends it. See [`StoreDatabase::local_commit_base`].
17///
18/// The turn is part of the reading rather than something a caller remembers to
19/// take: the position is only true for as long as no other local writer can
20/// take it. Hold this value until the commit composed from it has advanced the
21/// shared publication record, or until the candidate is durably persisted for
22/// a later publisher to activate.
23pub struct LocalCommitBase {
24    authorship: super::OwnStreamAuthorship,
25    state: LocalCommitState,
26}
27
28/// The accepted local position, authority cursors and publication observation
29/// captured by one database read while the caller owns the author's turn.
30pub struct LocalCommitState {
31    predecessor: Option<StoreBatchCommitRef>,
32    frontier: BTreeMap<String, StoreBatchCommitRef>,
33    membership: crate::InitialStoreMembershipAuthority,
34    publication: crate::StorePublicationBoundary,
35}
36
37impl LocalCommitBase {
38    pub fn into_parts(self) -> (super::OwnStreamAuthorship, LocalCommitState) {
39        (self.authorship, self.state)
40    }
41}
42
43impl LocalCommitState {
44    pub fn into_parts(
45        self,
46    ) -> (
47        Option<StoreBatchCommitRef>,
48        BTreeMap<String, StoreBatchCommitRef>,
49        crate::InitialStoreMembershipAuthority,
50        crate::StorePublicationBoundary,
51    ) {
52        (
53            self.predecessor,
54            self.frontier,
55            self.membership,
56            self.publication,
57        )
58    }
59}
60
61impl super::OwnStreamAuthorship {
62    /// Read the ledger belonging to this uninterrupted local author claim.
63    pub async fn local_commit_base(
64        self,
65        stream_id: AuthorStreamId,
66    ) -> Result<LocalCommitBase, DbError> {
67        let state = self.read_local_commit_state(stream_id).await?;
68        Ok(LocalCommitBase {
69            authorship: self,
70            state,
71        })
72    }
73
74    /// Capture preparation inputs while retaining this claim through row staging.
75    pub async fn read_local_commit_state(
76        &self,
77        stream_id: AuthorStreamId,
78    ) -> Result<LocalCommitState, DbError> {
79        self.database
80            .call_store(move |session| session.local_commit_ledger_base(&stream_id))
81            .await
82    }
83}
84
85impl StoreSession<'_> {
86    fn local_commit_ledger_base(
87        &self,
88        stream_id: &AuthorStreamId,
89    ) -> Result<LocalCommitState, DbError> {
90        let stream_id = stream_id.to_string();
91        Ok(LocalCommitState {
92            predecessor: crate::store::materialized_commit_index::latest_position_for_device_on(
93                self.conn, &stream_id,
94            )?,
95            frontier: crate::store::materialized_commit_index::materialized_frontier_on(
96                self.conn, None,
97            )?,
98            membership: crate::InitialStoreMembershipAuthority::load_on(self.conn)?,
99            publication: super::observed_store_publication::load_store_current_publication_on(
100                self.conn,
101            )?,
102        })
103    }
104
105    fn latest_local_store_position(
106        &self,
107        stream_id: &str,
108    ) -> Result<Option<StoreBatchCommitRef>, DbError> {
109        crate::store::materialized_commit_index::latest_position_for_device_on(self.conn, stream_id)
110    }
111
112    fn oldest_prepared_store_write(&mut self) -> Result<Option<PreparedStoreWriteCommit>, DbError> {
113        let records = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir);
114        let row = self
115            .conn
116            .query_row(
117                "SELECT write_id, base, prepared FROM store_writes
118                 WHERE prepared IS NOT NULL
119                   AND status = '\"publishing\"'
120                 ORDER BY ordinal LIMIT 1",
121                [],
122                |row| {
123                    Ok((
124                        row.get::<_, String>(0)?,
125                        row.get::<_, Option<String>>(1)?,
126                        row.get::<_, String>(2)?,
127                    ))
128                },
129            )
130            .optional()
131            .map_err(DbError::from)?;
132        row.map(|(write_id, base, prepared)| {
133            let base = base.ok_or_else(|| {
134                DbError::Message(format!(
135                    "publishing write {write_id} carries no commit base"
136                ))
137            })?;
138            let prepared: PreparedStoreWriteState = serde_json::from_str(&prepared)
139                .map_err(|error| DbError::context("prepared Store write", error))?;
140            let PreparedStoreWriteState { commit, .. } = &prepared;
141            let write_id = WriteId::from_generated(write_id);
142            let unverified_commit: StoreBatchCommit =
143                serde_json::from_slice(commit.semantic_bytes())
144                    .map_err(|error| DbError::context("prepared Store commit", error))?;
145            if unverified_commit.write_id != write_id {
146                return Err(DbError::Message(
147                    "prepared write id differs from signed commit".to_string(),
148                ));
149            }
150            let registration_ref = &unverified_commit.author_registration;
151            let stored_registration_ref: String = self
152                .conn
153                .query_row(
154                    "SELECT registration_object \
155                         FROM store_device_registration_activations \
156                         WHERE device_id = ?1 AND registration_hash = ?2",
157                    (
158                        registration_ref.device_id.to_string(),
159                        registration_ref.registration_hash.to_string(),
160                    ),
161                    |row| row.get(0),
162                )
163                .map_err(DbError::from)?;
164            let stored_registration_ref: StoreDeviceRegistrationRef =
165                serde_json::from_str(&stored_registration_ref)
166                    .map_err(|error| DbError::context("prepared write registration ref", error))?;
167            if stored_registration_ref != *registration_ref {
168                return Err(DbError::Message(
169                    "prepared commit registration differs from its activation".to_string(),
170                ));
171            }
172            let authority = self.activated_registration(registration_ref)?;
173            let registration = authority.value();
174            let root = &registration.store_root;
175            let stream_id =
176                coven_protocol::store_commit::StreamActivation::device_authorized_stream_id(
177                    root.store_root_hash,
178                    registration_ref,
179                    coven_protocol::store_commit::StreamAnchorDomain::StoreAnnouncements,
180                );
181            let coord = StoreCommitCoord {
182                stream_id,
183                sequence: unverified_commit.seq(),
184            };
185            let commit_value = VerifiedStoreBatchCommit::parse_prepared(
186                commit.semantic_bytes(),
187                root.store_root_hash,
188                coord,
189                commit.prepared().reference().clone(),
190                registration,
191            )
192            .map_err(|error| DbError::context("verify prepared Store commit", error))?;
193            let commit_ref = commit_value.reference().clone();
194            let active =
195                super::active_store_publication::load_active_store_publication_on(self.conn)?
196                    .ok_or_else(|| {
197                        DbError::Message(format!(
198                            "publishing write {write_id} has no active Store publication"
199                        ))
200                    })?;
201            if active.owner() != &crate::ActiveStorePublicationOwner::StoreWrite(write_id.clone())
202                || active.commit_reservation()
203                    != Some((&write_id, registration_ref, &commit_ref.coord))
204            {
205                return Err(DbError::Message(format!(
206                    "publishing write {write_id} differs from active Store publication {:?}",
207                    active.owner()
208                )));
209            }
210            let publication = active.attempt()?;
211            publication
212                .verify_commit(&commit_value)
213                .map_err(|error| DbError::context("verify prepared Store publication", error))?;
214            let base = records.effective_store_write_base(&write_id, &base)?;
215            let mut dependencies = CommitFrontier::from_refs(base.dependencies)
216                .map_err(|error| DbError::context("prepared dependency frontier", error))?;
217            let observed_predecessor = dependencies.0.remove(&stream_id);
218            if dependencies.commits() != commit_value.merge_dependencies() {
219                return Err(DbError::Message(
220                    "prepared commit differs from its write dependency frontier".to_string(),
221                ));
222            }
223            if observed_predecessor.as_ref().is_some_and(|captured| {
224                commit_value.order.predecessor().is_none_or(|current| {
225                    current.coord.sequence() < captured.coord.sequence()
226                        || current.coord.sequence() == captured.coord.sequence()
227                            && current != captured
228                })
229            }) {
230                return Err(DbError::Message(
231                    "prepared commit predecessor does not cover its write capture frontier"
232                        .to_string(),
233                ));
234            }
235            let partitions = records.store_write_partitions(write_id.as_str())?;
236            let audiences =
237                load_prepared_audience_objects_on(self.conn, self.store_dir, &write_id)?;
238            let graph_commit = &commit_value;
239            let expected_package_count = usize::from(graph_commit.store_package().is_some())
240                .checked_add(graph_commit.circle_packages().len())
241                .ok_or_else(|| DbError::Message("package count overflow".to_string()))?;
242            if audiences.packages.len() != expected_package_count
243                || audiences.packages.len()
244                    != usize::from(partitions.store.is_some()) + partitions.circles.len()
245            {
246                return Err(DbError::Message(
247                    "prepared package indexes do not exactly cover commit audiences".to_string(),
248                ));
249            }
250            for package in &audiences.packages {
251                let value = package.package();
252                if value.write_id() != &write_id
253                    || value.commit_coord() != &commit_ref.coord
254                    || value.candidate_family() != commit_value.candidate_family()
255                {
256                    return Err(DbError::Message(
257                        "indexed audience package differs from its exact commit".to_string(),
258                    ));
259                }
260                let expected_object = match value.audience() {
261                    coven_protocol::audience_package::PackageAudience::Store => {
262                        graph_commit
263                            .verify_store_package(package.semantic_bytes())
264                            .map_err(DbError::from)?;
265                        &graph_commit
266                            .store_package()
267                            .as_ref()
268                            .expect("verified present")
269                            .object
270                    }
271                    coven_protocol::audience_package::PackageAudience::Circle {
272                        circle_id, ..
273                    } => {
274                        graph_commit
275                            .verify_circle_package(*circle_id, package.semantic_bytes())
276                            .map_err(DbError::from)?;
277                        &graph_commit
278                            .circle_packages()
279                            .iter()
280                            .find(|entry| entry.circle_id == *circle_id)
281                            .expect("verified present")
282                            .package
283                            .object
284                    }
285                };
286                if package.object() != expected_object {
287                    return Err(DbError::Message(
288                        "indexed audience package exact object differs from its commit".to_string(),
289                    ));
290                }
291            }
292            for package in &audiences.packages {
293                let audience = package.package().audience().remote_audience();
294                for binding in package.package().blob_bindings() {
295                    if !audiences
296                        .blobs
297                        .iter()
298                        .any(|blob| blob.audience() == &audience && blob.blob() == binding.blob())
299                    {
300                        return Err(DbError::Message(
301                            "prepared package blob binding has no exact blob index".to_string(),
302                        ));
303                    }
304                }
305            }
306            for blob in &audiences.blobs {
307                if !audiences.packages.iter().any(|package| {
308                    package.package().audience().remote_audience() == *blob.audience()
309                        && package
310                            .package()
311                            .blob_bindings()
312                            .iter()
313                            .any(|binding| binding.blob() == blob.blob())
314                }) {
315                    return Err(DbError::Message(
316                        "prepared blob index has no exact package binding".to_string(),
317                    ));
318                }
319            }
320            Ok(PreparedStoreWriteCommit {
321                audiences,
322                commit: ExactProtocolObject {
323                    value: commit_value,
324                    bytes: commit.semantic_bytes().to_vec(),
325                    prepared: commit.prepared().clone(),
326                },
327                publication: publication.clone(),
328            })
329        })
330        .transpose()
331    }
332}
333
334impl StoreDatabase {
335    pub async fn oldest_prepared_store_write(
336        &self,
337    ) -> Result<Option<PreparedStoreWriteCommit>, DbError> {
338        let (loaded, covered) = self
339            .call_store(move |session| {
340                let loaded = session.oldest_prepared_store_write()?;
341                let covered = match &loaded {
342                    Some(batch) => super::StoreRecords::new(session.conn, session.store_dir)
343                        .covered_store_write(&batch.commit.value)?
344                        .is_some(),
345                    None => false,
346                };
347                Ok((loaded, covered))
348            })
349            .await?;
350        // Snapshot coverage makes the pending upload obsolete. A terminal cleanup
351        // retry may already have removed these files before its SQL transaction
352        // rolled back, and never needs their bytes to complete the receipt.
353        if !covered {
354            if let Some(batch) = &loaded {
355                for blob in &batch.audiences.blobs {
356                    if let Some(spool_path) = blob.spool_path() {
357                        {
358                            let (size, digest) =
359                                coven_foundation::local_file::file_facts(spool_path)
360                                    .await
361                                    .map_err(|error| {
362                                        DbError::context("prepared blob spool", error)
363                                    })?;
364                            blob.blob()
365                                .object()
366                                .verify_stored_facts(
367                                    spool_path,
368                                    size,
369                                    coven_protocol::store_commit::ObjectHash::from_digest(digest),
370                                )
371                                .map_err(|error| DbError::context("prepared blob spool", error))?;
372                        }
373                    }
374                }
375            }
376        }
377        Ok(loaded)
378    }
379
380    /// The local device's own latest position and the materialized frontier
381    /// that position belongs to, read as one state of the ledger.
382    ///
383    /// A commit order names one history, and both halves of it come from the
384    /// same table. Reading them separately lets one of this device's own
385    /// activations land in between, which leaves its own stream in the frontier
386    /// one commit ahead of the position it extends. Such an order has no
387    /// predecessor cut at all — the cut is the frontier with the predecessor
388    /// inserted, and those two then contradict each other on the author's own
389    /// stream — so every operation composed from it is refused. The device
390    /// driving an operation also runs its sync loop, so that is the ordinary
391    /// case rather than a hostile one.
392    ///
393    /// Taking this device's turn to author its own stream is part of the read:
394    /// the position returned stays this device's next position for as long as
395    /// the returned `LocalCommitBase` is held.
396    pub async fn local_commit_base(
397        &self,
398        stream_id: AuthorStreamId,
399    ) -> Result<LocalCommitBase, DbError> {
400        self.author_own_stream()
401            .await
402            .local_commit_base(stream_id)
403            .await
404    }
405
406    pub async fn latest_local_store_position(
407        &self,
408        stream_id: AuthorStreamId,
409    ) -> Result<Option<StoreBatchCommitRef>, DbError> {
410        self.call_store(move |session| session.latest_local_store_position(&stream_id.to_string()))
411            .await
412    }
413}