Skip to main content

coven_database/store/store_session/
active_store_publication.rs

1use super::{StoreDatabase, StoreSession};
2use crate::{ActiveStorePublication, ActiveStorePublicationOwner, DbError};
3use coven_protocol::store_commit::{StorePublicationPayload, VerifiedStoreBatchCommit};
4use rusqlite::OptionalExtension;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) enum ActiveStorePublicationClaim {
8    Acquired,
9    AlreadyOwned,
10    Occupied(ActiveStorePublicationOwner),
11}
12
13fn encode(value: &ActiveStorePublication) -> Result<String, DbError> {
14    serde_json::to_string(value)
15        .map_err(|error| DbError::context("serialize active Store publication", error))
16}
17
18fn decode(raw: &str) -> Result<ActiveStorePublication, DbError> {
19    serde_json::from_str(raw)
20        .map_err(|error| DbError::context("parse active Store publication", error))
21}
22
23pub(crate) fn load_active_store_publication_on(
24    conn: &rusqlite::Connection,
25) -> Result<Option<ActiveStorePublication>, DbError> {
26    conn.query_row(
27        "SELECT state FROM active_store_publication WHERE singleton = 1",
28        [],
29        |row| row.get::<_, String>(0),
30    )
31    .optional()
32    .map_err(DbError::from)?
33    .map(|raw| decode(&raw))
34    .transpose()
35}
36
37pub(crate) fn claim_active_store_publication_on(
38    conn: &rusqlite::Connection,
39    publication: &ActiveStorePublication,
40) -> Result<ActiveStorePublicationClaim, DbError> {
41    // Reserving an operation also retains its device signer. A cached writer
42    // cannot claim a new operation after recovery replaces that local signer.
43    // Recovery itself claims while its registration is Created, before a
44    // local device activation exists, so the registration journal is authority.
45    let (device_id, registration_hash, prepared): (String, String, String) = conn
46        .query_row(
47            "SELECT device_id, registration_hash, prepared_object \
48             FROM local_store_device_registration WHERE singleton = 1",
49            [],
50            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
51        )
52        .optional()?
53        .ok_or_else(|| {
54            DbError::Message("Store publication requires a local device registration".into())
55        })?;
56    let prepared: coven_protocol::objects::PreparedExactObject = serde_json::from_str(&prepared)
57        .map_err(|error| DbError::context("local Store registration object", error))?;
58    let author = publication.author_registration();
59    if device_id != author.device_id.to_string()
60        || registration_hash != author.registration_hash.to_string()
61        || prepared.reference() != &author.object
62    {
63        return Err(DbError::Message(
64            "Store publication author differs from the current local registration".into(),
65        ));
66    }
67    match load_active_store_publication_on(conn)? {
68        Some(active) if active == *publication => Ok(ActiveStorePublicationClaim::AlreadyOwned),
69        Some(active) => Ok(ActiveStorePublicationClaim::Occupied(
70            active.owner().clone(),
71        )),
72        None => {
73            conn.execute(
74                "INSERT INTO active_store_publication (singleton, state) VALUES (1, ?1)",
75                [encode(publication)?],
76            )
77            .map_err(DbError::from)?;
78            Ok(ActiveStorePublicationClaim::Acquired)
79        }
80    }
81}
82
83pub(crate) fn clear_active_store_publication_on(
84    conn: &rusqlite::Connection,
85    expected: &ActiveStorePublication,
86) -> Result<(), DbError> {
87    if expected.superseded_entry().is_some()
88        || !expected.retired_candidates().is_empty()
89        || !expected.retired_snapshot_objects().is_empty()
90    {
91        return Err(DbError::Message(
92            "Store publication still owns superseded entry cleanup".to_string(),
93        ));
94    }
95    let deleted = conn
96        .execute(
97            "DELETE FROM active_store_publication WHERE singleton = 1 AND state = ?1",
98            [encode(expected)?],
99        )
100        .map_err(DbError::from)?;
101    if deleted != 1 {
102        return Err(DbError::Message(
103            "active Store publication changed before completion".to_string(),
104        ));
105    }
106    Ok(())
107}
108
109pub(crate) fn clear_active_store_commit_for_owner_on(
110    conn: &rusqlite::Connection,
111    owner: &ActiveStorePublicationOwner,
112    candidate: &coven_protocol::store_commit::StoreBatchCommitRef,
113) -> Result<(), DbError> {
114    let active = load_active_store_publication_on(conn)?.ok_or_else(|| {
115        DbError::Message(format!(
116            "accepted Store candidate {candidate:?} has no active publication"
117        ))
118    })?;
119    if active.owner() != owner
120        || !matches!(
121            &active.attempt()?.entry.payload,
122            coven_protocol::store_commit::StorePublicationPayload::Commit(reference)
123                if reference == candidate
124        )
125    {
126        return Err(DbError::Message(format!(
127            "accepted Store candidate {candidate:?} differs from active publication {:?}",
128            active.owner()
129        )));
130    }
131    clear_active_store_publication_on(conn, &active)
132}
133
134pub(super) fn update_active_store_publication_on(
135    conn: &rusqlite::Connection,
136    expected: &ActiveStorePublication,
137    replacement: &ActiveStorePublication,
138) -> Result<(), DbError> {
139    let updated = conn
140        .execute(
141            "UPDATE active_store_publication SET state = ?1 \
142             WHERE singleton = 1 AND state = ?2",
143            [encode(replacement)?, encode(expected)?],
144        )
145        .map_err(DbError::from)?;
146    if updated != 1 {
147        return Err(DbError::Message(
148            "active Store publication changed before attempt replacement".to_string(),
149        ));
150    }
151    Ok(())
152}
153
154impl StoreSession<'_> {
155    fn active_store_publication(&self) -> Result<Option<ActiveStorePublication>, DbError> {
156        load_active_store_publication_on(self.conn)
157    }
158
159    fn replace_active_store_commit_publication(
160        &self,
161        commit: VerifiedStoreBatchCommit,
162        expected: ActiveStorePublication,
163        mut replacement: ActiveStorePublication,
164    ) -> Result<(), DbError> {
165        if expected.replace_attempt(replacement.attempt()?.clone())? != replacement {
166            return Err(DbError::Message(
167                "replacement changes the active Store publication owner or reservation".to_string(),
168            ));
169        }
170        if expected.commit_reservation()
171            != Some((
172                &commit.write_id,
173                &commit.author_registration,
174                &commit.reference().coord,
175            ))
176            || expected.attempt()?.entry.payload
177                != StorePublicationPayload::Commit(commit.reference().clone())
178            || expected.attempt()?.entry.payload != replacement.attempt()?.entry.payload
179        {
180            return Err(DbError::Message(
181                "Store candidate replacement must include its owning journal".to_string(),
182            ));
183        }
184        replacement.attempt()?.verify_commit(&commit)?;
185        replacement.attempt()?.prepared_entry()?;
186        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
187        let installed =
188            super::observed_store_publication::load_store_current_publication_on(&transaction)?;
189        if installed.record() != &replacement.attempt()?.previous
190            || installed.observed_version() != Some(&replacement.attempt()?.previous_version)
191        {
192            return Err(DbError::Message(
193                "replacement Store publication does not extend the installed boundary".to_string(),
194            ));
195        }
196        let superseded = expected.attempt()?.reference()?;
197        let entries = self.store_publication_entries()?;
198        let winner = entries
199            .iter()
200            .find(|entry| entry.value.position == superseded.position)
201            .ok_or_else(|| {
202                DbError::Message(
203                    "Store publication replacement lacks its settled exact position".to_string(),
204                )
205            })?;
206        if winner.prepared.reference() == &superseded.object {
207            return Err(DbError::Message(
208                "accepted Store publication cannot be replaced".to_string(),
209            ));
210        }
211        if let coven_protocol::store_commit::StorePublicationPayload::Commit(candidate) =
212            &expected.attempt()?.entry.payload
213        {
214            if entries.iter().any(|entry| {
215                matches!(
216                    &entry.value.payload,
217                    coven_protocol::store_commit::StorePublicationPayload::Commit(accepted)
218                        if accepted.coord == candidate.coord
219                )
220            }) {
221                return Err(DbError::Message(
222                    "accepted Store operation cannot receive another publication attempt"
223                        .to_string(),
224                ));
225            }
226        }
227        replacement.retain_superseded_entry(superseded)?;
228        update_active_store_publication_on(&transaction, &expected, &replacement)?;
229        transaction.commit().map_err(DbError::from)
230    }
231
232    fn begin_retired_store_write_discard(
233        &self,
234        expected: ActiveStorePublication,
235    ) -> Result<ActiveStorePublication, DbError> {
236        let tx = self.conn.unchecked_transaction()?;
237        if load_active_store_publication_on(&tx)?.as_ref() != Some(&expected) {
238            return Err(DbError::Message(
239                "discarding Store publication changed".to_string(),
240            ));
241        }
242        let ActiveStorePublicationOwner::StoreWrite(write_id) = expected.owner() else {
243            return Err(DbError::Message(
244                "only a Store write can discard its captured rows".to_string(),
245            ));
246        };
247        let status: String = tx.query_row(
248            "SELECT status FROM store_writes WHERE write_id = ?1",
249            [write_id.as_str()],
250            |row| row.get(0),
251        )?;
252        let status: coven_protocol::write::WriteStatus = serde_json::from_str(&status)
253            .map_err(|error| DbError::context("write status before discard", error))?;
254        if !matches!(status, coven_protocol::write::WriteStatus::Blocked(_)) {
255            return Err(DbError::Message(
256                "retired write must be blocked before discard".to_string(),
257            ));
258        }
259        if expected.is_discarding() {
260            return Ok(expected);
261        }
262        let replacement = expected.begin_discard()?;
263        for retired in expected.retired_candidates() {
264            super::candidate_records::begin_candidate_nonactivation_targets_on(
265                &tx,
266                &retired.candidate()?,
267                &retired.objects()?,
268                &retired.nonactivation,
269            )?;
270        }
271        update_active_store_publication_on(&tx, &expected, &replacement)?;
272        tx.commit()?;
273        Ok(replacement)
274    }
275
276    fn retired_store_write_cleanup(
277        &self,
278        expected: &ActiveStorePublication,
279    ) -> Result<Vec<super::candidate_records::CandidateCleanupObject>, DbError> {
280        if load_active_store_publication_on(self.conn)?.as_ref() != Some(expected)
281            || (expected.is_awaiting_preparation()
282                && expected.owner() != &ActiveStorePublicationOwner::MembershipMutation)
283        {
284            return Err(DbError::Message(
285                "retired candidate cleanup requires its prepared replacement owner".to_string(),
286            ));
287        }
288        let mut targets = std::collections::BTreeMap::new();
289        for retired in expected.retired_candidates() {
290            for target in super::candidate_records::candidate_cleanup_targets_on(
291                self.conn,
292                &retired.candidate()?,
293                &retired.objects()?,
294            )? {
295                targets.insert(target.object.clone(), target);
296            }
297        }
298        Ok(targets.into_values().collect())
299    }
300
301    fn complete_retired_store_write_cleanup(
302        &self,
303        expected: ActiveStorePublication,
304    ) -> Result<(), DbError> {
305        let tx = self.conn.unchecked_transaction()?;
306        if load_active_store_publication_on(&tx)?.as_ref() != Some(&expected)
307            || (expected.is_awaiting_preparation()
308                && expected.owner() != &ActiveStorePublicationOwner::MembershipMutation)
309        {
310            return Err(DbError::Message(
311                "retired candidate owner changed before cleanup completion".to_string(),
312            ));
313        }
314        let targets = self.retired_store_write_cleanup(&expected)?;
315        let mut removed = Vec::new();
316        for target in targets {
317            let id = coven_protocol::remote_object::remote_object_id(&target.object);
318            let mut remote = crate::load_remote_object_on(&tx, id)?;
319            remote.mark_absent_verified()?;
320            crate::update_remote_object_on(&tx, id, &remote)?;
321            removed.push(id);
322        }
323        for retired in expected.retired_candidates() {
324            super::candidate_records::require_candidate_cleanup_complete_on(
325                &tx,
326                &retired.candidate()?,
327                &retired.objects()?,
328                "retired candidate cleanup is incomplete",
329            )?;
330        }
331        if expected.owner() == &ActiveStorePublicationOwner::MembershipMutation {
332            // The request's signed original authority remains bound to this
333            // reservation until replacement or verified satisfaction consumes it.
334            tx.commit()?;
335            return Ok(());
336        }
337        for retired in expected.retired_candidates() {
338            for blob in retired.blobs() {
339                let remote = crate::load_remote_object_on(&tx, blob.remote_object_id())?;
340                let verified = crate::PreparedAudienceBlob::from_remote(
341                    blob.audience().clone(),
342                    &blob.blob().locator().locator_hash().to_string(),
343                    remote,
344                    blob.spool_path().map(std::path::Path::to_path_buf),
345                )?;
346                if &verified != blob {
347                    return Err(DbError::Message(
348                        "retired blob source differs from its exact owner".to_string(),
349                    ));
350                }
351                let Some(path) = blob.spool_path() else {
352                    continue;
353                };
354                if path
355                    != self
356                        .store_dir
357                        .outbound_blob_spool_path(blob.blob().locator().locator_hash())
358                {
359                    return Err(DbError::Message(
360                        "retired blob spool differs from its locator".to_string(),
361                    ));
362                }
363                if retained_blob_spool_has_claim_on(&tx, path)? {
364                    continue;
365                }
366                match std::fs::remove_file(path) {
367                    Ok(()) => {}
368                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
369                    Err(source) => {
370                        return Err(coven_foundation::atomic_file::FileError::Path {
371                            operation: "remove retired Store write blob spool",
372                            path: path.to_path_buf(),
373                            source,
374                        }
375                        .into());
376                    }
377                }
378                coven_foundation::atomic_file::sync_parent_dir_blocking(path)?;
379            }
380        }
381        super::candidate_records::delete_remote_objects_on(
382            &tx,
383            removed,
384            "retired Store write candidate",
385        )?;
386        let mut completed = expected.clone();
387        completed.complete_retired_candidate_cleanup()?;
388        update_active_store_publication_on(&tx, &expected, &completed)?;
389        tx.commit()?;
390        Ok(())
391    }
392
393    fn complete_superseded_publication_cleanup(
394        &self,
395        expected: ActiveStorePublication,
396    ) -> Result<(), DbError> {
397        let mut completed = expected.clone();
398        completed.complete_superseded_entry_cleanup()?;
399        update_active_store_publication_on(self.conn, &expected, &completed)
400    }
401}
402
403impl StoreDatabase {
404    pub async fn begin_retired_store_write_discard(
405        &self,
406        expected: ActiveStorePublication,
407    ) -> Result<ActiveStorePublication, DbError> {
408        self.call_store(move |session| session.begin_retired_store_write_discard(expected))
409            .await
410    }
411
412    pub async fn active_store_publication(
413        &self,
414    ) -> Result<Option<ActiveStorePublication>, DbError> {
415        self.call_store(|session| session.active_store_publication())
416            .await
417    }
418
419    pub async fn replace_active_store_commit_publication(
420        &self,
421        commit: VerifiedStoreBatchCommit,
422        expected: ActiveStorePublication,
423        replacement: ActiveStorePublication,
424    ) -> Result<(), DbError> {
425        self.call_store(move |session| {
426            session.replace_active_store_commit_publication(commit, expected, replacement)
427        })
428        .await
429    }
430
431    pub async fn retired_store_write_cleanup(
432        &self,
433        expected: ActiveStorePublication,
434    ) -> Result<Vec<super::candidate_records::CandidateCleanupObject>, DbError> {
435        self.call_store(move |session| session.retired_store_write_cleanup(&expected))
436            .await
437    }
438
439    pub async fn complete_retired_store_write_cleanup(
440        &self,
441        expected: ActiveStorePublication,
442    ) -> Result<(), DbError> {
443        self.call_store(move |session| session.complete_retired_store_write_cleanup(expected))
444            .await
445    }
446
447    pub async fn complete_superseded_publication_cleanup(
448        &self,
449        expected: ActiveStorePublication,
450    ) -> Result<(), DbError> {
451        self.call_store(move |session| session.complete_superseded_publication_cleanup(expected))
452            .await
453    }
454}
455
456/// Read the existing owners while the database job keeps new claims from racing
457/// the unlink. The caller also holds the upload and snapshot publication permits
458/// across this job, covering files prepared before those owners persist them.
459pub(super) fn retained_blob_spool_has_claim_on(
460    conn: &rusqlite::Connection,
461    path: &std::path::Path,
462) -> Result<bool, DbError> {
463    let encoded = path
464        .to_str()
465        .ok_or_else(|| DbError::Message("blob spool path is not UTF-8".to_string()))?;
466    if conn.query_row(
467        "SELECT EXISTS(SELECT 1 FROM store_write_blobs WHERE spool_path = ?1)",
468        [encoded],
469        |row| row.get::<_, bool>(0),
470    )? {
471        return Ok(true);
472    }
473    for encoded in crate::query_mapped_rows(
474        conn,
475        "SELECT upload_state FROM cloud_outbox WHERE operation = 'upload'",
476        [],
477        |row| row.get::<_, String>(0),
478    )? {
479        let state: super::blob_outbox::OutboxUploadState = serde_json::from_str(&encoded)
480            .map_err(|error| DbError::context("outbox blob spool owner", error))?;
481        match state {
482            super::blob_outbox::OutboxUploadState::Pending => {}
483            super::blob_outbox::OutboxUploadState::Prepared { spool_path, .. }
484            | super::blob_outbox::OutboxUploadState::Created { spool_path, .. }
485                if spool_path == path =>
486            {
487                return Ok(true);
488            }
489            super::blob_outbox::OutboxUploadState::Prepared { .. }
490            | super::blob_outbox::OutboxUploadState::Created { .. } => {}
491        }
492    }
493    Ok(false)
494}