Skip to main content

coven_database/store/store_session/
write_lifecycle.rs

1use crate::*;
2use coven_protocol::write::{PendingWrite, WriteId, WriteResolution, WriteStatus};
3use std::sync::Arc;
4
5use super::*;
6
7#[derive(Debug, PartialEq, Eq)]
8pub enum BlockedWriteDiscard {
9    Discarded(Vec<coven_protocol::write::WriteId>),
10    RemoteResolutionRequired,
11}
12
13impl StoreSession<'_> {
14    fn pending_writes(&self) -> Result<Vec<PendingWrite>, DbError> {
15        let mut statement = self
16            .conn
17            .prepare(
18                "SELECT write_id, status, affected_rows FROM store_writes
19                 WHERE status IN ('\"pending\"', '\"publishing\"')
20                    OR json_extract(status, '$.blocked') IS NOT NULL
21                    OR json_extract(status, '$.local_only_blocked') IS NOT NULL
22                 ORDER BY ordinal",
23            )
24            .map_err(DbError::from)?;
25        let rows = statement
26            .query_map([], |row| {
27                Ok((
28                    row.get::<_, String>(0)?,
29                    row.get::<_, String>(1)?,
30                    row.get::<_, Option<String>>(2)?,
31                ))
32            })
33            .map_err(DbError::from)?;
34        rows.map(|row| {
35            let (write_id, status, affected_rows) = row.map_err(DbError::from)?;
36            // Only a folded write has no affected rows, and the fold stops at
37            // the first write that is not settled — which every write selected
38            // here is not.
39            let affected_rows = affected_rows.ok_or_else(|| {
40                DbError::Message(format!("unpublished write {write_id} has been folded"))
41            })?;
42            Ok(PendingWrite {
43                write_id: WriteId::from_generated(write_id),
44                status: serde_json::from_str(&status)
45                    .map_err(|error| DbError::context("pending write status", error))?,
46                affected_rows: serde_json::from_str(&affected_rows)
47                    .map_err(|error| DbError::context("pending affected rows", error))?,
48            })
49        })
50        .collect()
51    }
52
53    /// Where each published write landed, in publication order.
54    ///
55    /// The device's own record of its writes, which survives a replay-baseline
56    /// advance — the per-position `materialized_commits` rows do not, because
57    /// the advance retires the retained rows they name.
58    #[cfg(any(test, feature = "test-utils"))]
59    fn published_write_commits(
60        &self,
61    ) -> Result<Vec<coven_protocol::store_commit::StoreBatchCommitRef>, DbError> {
62        use coven_protocol::write::PublishedWrite;
63        let rows = crate::query_mapped_rows(
64            self.conn,
65            "SELECT status FROM store_writes ORDER BY ordinal",
66            [],
67            |row| row.get::<_, String>(0),
68        )?;
69        let mut commits = Vec::new();
70        for raw in rows {
71            let status: WriteStatus = serde_json::from_str(&raw)
72                .map_err(|error| DbError::context("published write status", error))?;
73            if let WriteStatus::Published(position) = status {
74                match *position {
75                    PublishedWrite::Commit(position) => commits.push(position.commit),
76                    PublishedWrite::Snapshot(_) => {
77                        return Err(DbError::Message(
78                            "published-write commit lookup encountered a snapshot-covered receipt"
79                                .into(),
80                        ));
81                    }
82                }
83            }
84        }
85        Ok(commits)
86    }
87
88    fn set_write_status(&self, write_id: &WriteId, status: &WriteStatus) -> Result<(), DbError> {
89        Database::set_write_status_on(self.conn, write_id, status)
90    }
91
92    fn block_write_if_unresolved(
93        &self,
94        write_id: &WriteId,
95        block: coven_protocol::write::WriteBlock,
96    ) -> Result<Option<WriteStatus>, DbError> {
97        let raw: String = self
98            .conn
99            .query_row(
100                "SELECT status FROM store_writes WHERE write_id = ?1",
101                [write_id.as_str()],
102                |row| row.get(0),
103            )
104            .map_err(DbError::from)?;
105        let current: WriteStatus = serde_json::from_str(&raw).map_err(|error| {
106            DbError::context(format!("write {write_id} status before blocking"), error)
107        })?;
108        match current {
109            WriteStatus::Resolved(_) => Ok(None),
110            WriteStatus::Pending | WriteStatus::Publishing | WriteStatus::Blocked(_) => {
111                let blocked = WriteStatus::Blocked(block);
112                Database::set_write_status_on(self.conn, write_id, &blocked)?;
113                Ok(Some(blocked))
114            }
115            WriteStatus::LocalOnly | WriteStatus::LocalOnlyBlocked(_) => {
116                let blocked = WriteStatus::LocalOnlyBlocked(block);
117                Database::set_write_status_on(self.conn, write_id, &blocked)?;
118                Ok(Some(blocked))
119            }
120            state @ WriteStatus::Published(_) => Err(DbError::Message(format!(
121                "write {write_id} cannot become blocked from {state:?}",
122            ))),
123        }
124    }
125
126    fn retry_blocked_write(
127        &mut self,
128        write_id: WriteId,
129    ) -> Result<Vec<(WriteId, WriteStatus)>, DbError> {
130        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
131        if let Some(active) =
132            super::active_store_publication::load_active_store_publication_on(&tx)?
133        {
134            if active.owner() == &ActiveStorePublicationOwner::StoreWrite(write_id.clone())
135                && active.is_discarding()
136            {
137                return Err(DbError::Message(format!(
138                    "discard of write {write_id} has begun and publication cannot resume"
139                )));
140            }
141        }
142        let (raw_status, prepared): (String, Option<String>) = tx
143            .query_row(
144                "SELECT status, prepared FROM store_writes WHERE write_id = ?1",
145                [write_id.as_str()],
146                |row| Ok((row.get(0)?, row.get(1)?)),
147            )
148            .map_err(DbError::from)?;
149        let status: WriteStatus = serde_json::from_str(&raw_status)
150            .map_err(|error| DbError::context(format!("blocked write {write_id} status"), error))?;
151        if !matches!(
152            status,
153            WriteStatus::Blocked(_) | WriteStatus::LocalOnlyBlocked(_)
154        ) {
155            return Err(DbError::Message(format!("write {write_id} is not blocked")));
156        }
157        let local_only = matches!(status, WriteStatus::LocalOnlyBlocked(_));
158        if local_only && prepared.is_some() {
159            return Err(DbError::Message(format!(
160                "private-only write {write_id} carries a publication candidate"
161            )));
162        }
163        let next = if local_only {
164            WriteStatus::LocalOnly
165        } else if prepared.is_some() {
166            WriteStatus::Publishing
167        } else {
168            WriteStatus::Pending
169        };
170        let next_json = serde_json::to_string(&next)
171            .map_err(|error| DbError::context("serialize retry status", error))?;
172        let updated = tx
173            .execute(
174                "UPDATE store_writes SET status = ?2
175                 WHERE write_id = ?1 AND status = ?3",
176                rusqlite::params![write_id.as_str(), next_json, raw_status],
177            )
178            .map_err(DbError::from)?;
179        if updated != 1 {
180            return Err(DbError::Message(format!(
181                "blocked write {write_id} changed during retry"
182            )));
183        }
184        let retried = vec![(write_id, next)];
185        tx.commit().map_err(DbError::from)?;
186        Ok(retried)
187    }
188
189    fn discard_blocked_write(&mut self, write_id: WriteId) -> Result<BlockedWriteDiscard, DbError> {
190        let tx = self.conn.unchecked_transaction().map_err(DbError::from)?;
191        let (raw_status, target_ordinal): (String, i64) = tx
192            .query_row(
193                "SELECT status, ordinal FROM store_writes WHERE write_id = ?1",
194                [write_id.as_str()],
195                |row| Ok((row.get(0)?, row.get(1)?)),
196            )
197            .map_err(DbError::from)?;
198        let target_status: WriteStatus = serde_json::from_str(&raw_status)
199            .map_err(|error| DbError::context(format!("blocked write {write_id} status"), error))?;
200        if !matches!(
201            target_status,
202            WriteStatus::Blocked(_) | WriteStatus::LocalOnlyBlocked(_)
203        ) {
204            return Err(DbError::Message(format!("write {write_id} is not blocked")));
205        }
206
207        let mut statement = tx
208            .prepare(
209                "SELECT write_id, status, changeset_hash FROM store_writes
210                 WHERE ordinal >= ?1
211                   AND json_extract(status, '$.published') IS NULL
212                   AND json_extract(status, '$.resolved') IS NULL
213                 ORDER BY ordinal",
214            )
215            .map_err(DbError::from)?;
216        let rows = statement
217            .query_map([target_ordinal], |row| {
218                Ok((
219                    row.get::<_, String>(0)?,
220                    row.get::<_, String>(1)?,
221                    row.get::<_, Option<String>>(2)?,
222                ))
223            })
224            .map_err(DbError::from)?;
225        let mut discarded = Vec::new();
226        for row in rows {
227            let (stored_id, raw_status, changeset_hash) = row.map_err(DbError::from)?;
228            // The changeset is what reversing an unpublished write needs, and
229            // only a folded write is without one. The fold stops at the first
230            // unsettled write, so no write in this suffix can have been folded.
231            let changeset_hash = changeset_hash.ok_or_else(|| {
232                DbError::Message(format!("unpublished write {stored_id} has been folded"))
233            })?;
234            let status: WriteStatus = serde_json::from_str(&raw_status)
235                .map_err(|error| DbError::context("discard write status", error))?;
236            if !matches!(
237                status,
238                WriteStatus::LocalOnly
239                    | WriteStatus::LocalOnlyBlocked(_)
240                    | WriteStatus::Pending
241                    | WriteStatus::Blocked(_)
242            ) {
243                return Err(DbError::Message(format!(
244                    "write {stored_id} after blocked write {write_id} has non-discardable status {status:?}"
245                )));
246            }
247            let discarded_id = WriteId::from_generated(stored_id);
248            let actual_hash =
249                match StoreRecords::new(&tx, self.store_dir).rebased_store_write(&discarded_id)? {
250                    Some(rebased) => rebased.changeset_hash,
251                    None => changeset_hash.parse::<coven_protocol::store_commit::ObjectHash>()?,
252                };
253            discarded.push((discarded_id, actual_hash));
254        }
255        drop(statement);
256        if discarded.first().map(|(stored_id, _)| stored_id) != Some(&write_id) {
257            return Err(DbError::Message(format!(
258                "blocked write {write_id} is absent from its unpublished suffix"
259            )));
260        }
261        for (discarded_id, _) in &discarded {
262            if !crate::store::store_session::StoreTransaction::new(&tx, self.store_dir)
263                .unpublished_write_cleanup_is_complete(
264                    self.verified_store_authority,
265                    discarded_id,
266                )?
267            {
268                return Ok(BlockedWriteDiscard::RemoteResolutionRequired);
269            }
270        }
271        let schema = Arc::new(crate::TableSchema::for_apply(
272            &tx,
273            self.synced_tables,
274            self.gates,
275        )?);
276        let store_transaction =
277            crate::store::store_session::StoreTransaction::new(&tx, self.store_dir);
278        let mut inverses = Vec::with_capacity(discarded.len());
279        let mut restored_blobs = Vec::new();
280        for (_, changeset_hash) in discarded.iter().rev() {
281            let changeset = store_transaction.payload(*changeset_hash)?;
282            let inverse = StoreDatabase::invert_changeset(&changeset)?;
283            for change in crate::walk_changeset(&inverse).map_err(DbError::Changeset)? {
284                if let Some(blob) = self
285                    .blob_decls
286                    .ref_from_change(&change)
287                    .map_err(DbError::from)?
288                {
289                    restored_blobs.push(blob);
290                }
291            }
292            let inverse = crate::ValidatedChangeset::new(inverse, schema.clone())
293                .map_err(|error| DbError::context("invalid blocked-write inverse", error))?;
294            inverses.push(inverse);
295        }
296        let suspended_cleanup =
297            super::local_blob_cleanup::suspend_leased_blob_cleanup_for_restoration_on(
298                &tx,
299                &restored_blobs,
300            )?;
301        for inverse in inverses {
302            MergeMaterializationTransaction::from_store(
303                crate::store::store_session::StoreTransaction::new(&tx, self.store_dir),
304            )
305            .apply_changeset_strict(inverse, self.blob_decls)
306            .map_err(|error| DbError::context("reverse blocked-write suffix", error))?;
307        }
308        super::local_blob_cleanup::reevaluate_suspended_blob_cleanup_on(
309            &tx,
310            self.blob_decls,
311            &suspended_cleanup,
312        )?;
313        let discarded_ids: Vec<_> = discarded
314            .into_iter()
315            .map(|(write_id, _)| write_id)
316            .collect();
317        let resolution = WriteResolution::Discarded;
318        crate::store::store_session::StoreTransaction::new(&tx, self.store_dir)
319            .resolve_unpublished_writes(
320                self.verified_store_authority,
321                &discarded_ids,
322                &resolution,
323            )?;
324        tx.commit().map_err(DbError::from)?;
325        Ok(BlockedWriteDiscard::Discarded(discarded_ids))
326    }
327}
328
329impl StoreDatabase {
330    #[doc(hidden)]
331    pub async fn pending_writes(&self) -> Result<Vec<PendingWrite>, DbError> {
332        self.call_store(|session| session.pending_writes()).await
333    }
334
335    #[cfg(any(test, feature = "test-utils"))]
336    pub async fn published_write_commits(
337        &self,
338    ) -> Result<Vec<coven_protocol::store_commit::StoreBatchCommitRef>, DbError> {
339        self.call_store(|session| session.published_write_commits())
340            .await
341    }
342
343    #[doc(hidden)]
344    pub async fn blocked_writes(&self) -> Result<Vec<PendingWrite>, DbError> {
345        Ok(self
346            .pending_writes()
347            .await?
348            .into_iter()
349            .filter(|write| {
350                matches!(
351                    write.status,
352                    WriteStatus::Blocked(_) | WriteStatus::LocalOnlyBlocked(_)
353                )
354            })
355            .collect())
356    }
357
358    #[doc(hidden)]
359    pub async fn subscribe_write_status(
360        &self,
361        write_id: &WriteId,
362    ) -> Result<tokio::sync::watch::Receiver<WriteStatus>, DbError> {
363        let write_id = write_id.clone();
364        let current = self
365            .call_store({
366                let write_id = write_id.clone();
367                move |session| session.write_status(&write_id)
368            })
369            .await?;
370        Ok(self.subscribe_store_write_status(write_id, current))
371    }
372
373    pub async fn set_write_status(
374        &self,
375        write_id: &WriteId,
376        status: WriteStatus,
377    ) -> Result<(), DbError> {
378        let stored_id = write_id.clone();
379        let stored_status = status.clone();
380        self.call_store(move |session| session.set_write_status(&stored_id, &stored_status))
381            .await?;
382        self.notify_write_status(write_id.clone(), status);
383        Ok(())
384    }
385
386    pub async fn block_write_if_unresolved(
387        &self,
388        write_id: &WriteId,
389        block: coven_protocol::write::WriteBlock,
390    ) -> Result<bool, DbError> {
391        let write_id = write_id.clone();
392        let notified_write_id = write_id.clone();
393        let outcome = self
394            .call_store(move |session| session.block_write_if_unresolved(&write_id, block))
395            .await?;
396        if let Some(status) = outcome {
397            self.notify_write_status(notified_write_id, status);
398            Ok(true)
399        } else {
400            Ok(false)
401        }
402    }
403
404    /// Retry one blocked write under its original publication obligation.
405    /// Private-only writes remain local. Shared writes return to preparation or
406    /// their retained candidate; another semantic failure records a fresh block.
407    #[doc(hidden)]
408    pub async fn retry_blocked_write(&self, write_id: &WriteId) -> Result<Vec<WriteId>, DbError> {
409        let write_id = write_id.clone();
410        let retried = self
411            .call_store(move |session| session.retry_blocked_write(write_id))
412            .await?;
413        let retried_ids = retried
414            .iter()
415            .map(|(write_id, _)| write_id.clone())
416            .collect();
417        for (write_id, status) in retried {
418            self.notify_write_status(write_id, status);
419        }
420        Ok(retried_ids)
421    }
422
423    /// Atomically reverse a blocked write and every later unpublished write,
424    /// including private-only writes whose working rows depend on it.
425    #[doc(hidden)]
426    pub async fn discard_blocked_write(
427        &self,
428        write_id: &WriteId,
429    ) -> Result<BlockedWriteDiscard, DbError> {
430        let write_id = write_id.clone();
431        let discarded_ids = self
432            .call_store(move |session| session.discard_blocked_write(write_id))
433            .await?;
434        if let BlockedWriteDiscard::Discarded(discarded_ids) = &discarded_ids {
435            let status = WriteStatus::Resolved(WriteResolution::Discarded);
436            for discarded_id in discarded_ids {
437                self.notify_write_status(discarded_id.clone(), status.clone());
438            }
439        }
440        Ok(discarded_ids)
441    }
442}