Skip to main content

coven_database/store/store_session/
local_blob_cleanup.rs

1use tracing::debug;
2
3use super::*;
4use crate::local_blob_cleanup_intents::{LocalBlobCleanupIdentity, LocalBlobCleanupIntent};
5use crate::BlobDecls;
6
7pub(crate) struct ExactBlobBindings {
8    by_row: std::collections::BTreeMap<(String, String), coven_protocol::store_commit::ObjectHash>,
9}
10
11pub(crate) fn exact_blob_bindings_on(
12    conn: &rusqlite::Connection,
13) -> Result<ExactBlobBindings, DbError> {
14    let mut statement = conn
15        .prepare(
16            "SELECT binding.table_name, binding.row_id, locator.locator_hash
17             FROM row_blob_locators AS binding
18             JOIN blob_locators AS locator
19               ON locator.remote_object_id = binding.remote_object_id",
20        )
21        .map_err(DbError::from)?;
22    let rows = statement
23        .query_map([], |row| {
24            Ok((
25                row.get::<_, String>(0)?,
26                row.get::<_, String>(1)?,
27                row.get::<_, String>(2)?,
28            ))
29        })
30        .map_err(DbError::from)?;
31    let mut by_row = std::collections::BTreeMap::new();
32    for row in rows {
33        let (table, row_id, encoded) = row.map_err(DbError::from)?;
34        let locator_hash = encoded
35            .parse::<coven_protocol::store_commit::ObjectHash>()
36            .map_err(|error| DbError::context("parse local cleanup locator hash", error))?;
37        if let Some(existing) = by_row.insert((table.clone(), row_id.clone()), locator_hash) {
38            if existing != locator_hash {
39                return Err(DbError::Message(format!(
40                    "local cleanup for {table}.{row_id} has distinct exact locator bindings"
41                )));
42            }
43        }
44    }
45    Ok(ExactBlobBindings { by_row })
46}
47
48/// Record cleanup obligations for each copy identity no live row needs in this
49/// transaction. The transaction mutating the carrying rows must also record the
50/// obligation, so the obsolete state and its cleanup commit together.
51pub(crate) fn record_obsolete_copy_intents_on(
52    conn: &rusqlite::Connection,
53    decls: &BlobDecls,
54    intent: &LocalBlobCleanupIntent,
55) -> Result<(), DbError> {
56    match intent.identity() {
57        LocalBlobCleanupIdentity::Local => {
58            let local_referenced = decls
59                .local_copy_is_referenced(conn, intent.namespace(), intent.blob_id())
60                .map_err(DbError::from)?;
61            if !local_referenced {
62                record_durable_intent(conn, intent)?;
63            }
64        }
65        LocalBlobCleanupIdentity::Exact(_) => {
66            return Err(DbError::Message(
67                "exact local cleanup identity is already durable".to_string(),
68            ));
69        }
70        LocalBlobCleanupIdentity::Row { table, row_id } => {
71            record_obsolete_row_copy_intents_on(
72                conn,
73                decls,
74                intent,
75                exact_blob_binding_for_row_on(conn, table, row_id)?,
76            )?;
77        }
78    }
79    Ok(())
80}
81
82fn exact_blob_binding_for_row_on(
83    conn: &rusqlite::Connection,
84    table: &str,
85    row_id: &str,
86) -> Result<Option<coven_protocol::store_commit::ObjectHash>, DbError> {
87    let mut statement = conn
88        .prepare(
89            "SELECT DISTINCT locator.locator_hash
90             FROM row_blob_locators AS binding
91             JOIN blob_locators AS locator
92               ON locator.remote_object_id = binding.remote_object_id
93             WHERE binding.table_name = ?1 AND binding.row_id = ?2",
94        )
95        .map_err(DbError::from)?;
96    let locator_hashes = statement
97        .query_map((table, row_id), |row| row.get::<_, String>(0))
98        .map_err(DbError::from)?
99        .collect::<Result<Vec<_>, _>>()
100        .map_err(DbError::from)?;
101    match locator_hashes.as_slice() {
102        [] => Ok(None),
103        [encoded] => encoded
104            .parse()
105            .map(Some)
106            .map_err(|error| DbError::context("parse local cleanup locator hash", error)),
107        _ => Err(DbError::Message(format!(
108            "local cleanup for {table}.{row_id} has {} distinct exact locator bindings",
109            locator_hashes.len()
110        ))),
111    }
112}
113
114pub(crate) fn record_obsolete_copy_intents_from_bindings_on(
115    conn: &rusqlite::Connection,
116    decls: &BlobDecls,
117    intent: &LocalBlobCleanupIntent,
118    bindings: &ExactBlobBindings,
119) -> Result<(), DbError> {
120    match intent.identity() {
121        LocalBlobCleanupIdentity::Row { table, row_id } => record_obsolete_row_copy_intents_on(
122            conn,
123            decls,
124            intent,
125            bindings
126                .by_row
127                .get(&(table.clone(), row_id.clone()))
128                .copied(),
129        ),
130        _ => record_obsolete_copy_intents_on(conn, decls, intent),
131    }
132}
133
134fn record_obsolete_row_copy_intents_on(
135    conn: &rusqlite::Connection,
136    decls: &BlobDecls,
137    intent: &LocalBlobCleanupIntent,
138    exact_locator_hash: Option<coven_protocol::store_commit::ObjectHash>,
139) -> Result<(), DbError> {
140    if let Some(locator_hash) = exact_locator_hash {
141        let exact =
142            LocalBlobCleanupIntent::exact(intent.namespace(), intent.blob_id(), locator_hash);
143        let referenced = decls
144            .exact_copy_is_referenced(conn, exact.namespace(), exact.blob_id(), locator_hash)
145            .map_err(DbError::from)?;
146        if !referenced {
147            record_durable_intent(conn, &exact)?;
148        }
149    }
150    let local_referenced = decls
151        .local_copy_is_referenced(conn, intent.namespace(), intent.blob_id())
152        .map_err(DbError::from)?;
153    if !local_referenced {
154        record_durable_intent(
155            conn,
156            &LocalBlobCleanupIntent::local(intent.namespace(), intent.blob_id()),
157        )?;
158    }
159    Ok(())
160}
161
162fn record_durable_intent(
163    conn: &rusqlite::Connection,
164    intent: &LocalBlobCleanupIntent,
165) -> Result<(), DbError> {
166    let persisted_identity = intent.persisted_identity()?;
167    let inserted = crate::with_coven_sql_authority(|| {
168        conn.execute(
169            "INSERT OR IGNORE INTO local_cleanup_intents (namespace, blob_id, copy_identity)
170             VALUES (?1, ?2, ?3)",
171            (intent.namespace(), intent.blob_id(), persisted_identity),
172        )
173        .map_err(DbError::from)
174    })?;
175    if inserted == 0 {
176        debug!(
177            namespace = %intent.namespace(),
178            blob_id = %intent.blob_id(),
179            "local blob cleanup intent already exists"
180        );
181    }
182    Ok(())
183}
184
185pub(crate) struct SuspendedBlobCleanup {
186    local: Vec<LocalBlobCleanupIntent>,
187    published: Vec<super::blob_outbox::PublishedBlobDropIntent>,
188}
189
190/// Temporarily remove cleanup obligations for blobs whose bytes a replay lease
191/// still owns. The caller must reevaluate every returned intent against the
192/// resulting rows before committing its transaction.
193pub(crate) fn suspend_leased_blob_cleanup_for_restoration_on(
194    conn: &rusqlite::Connection,
195    blobs: &[coven_protocol::blob::BlobRef],
196) -> Result<SuspendedBlobCleanup, DbError> {
197    let blob_keys = blobs
198        .iter()
199        .map(|blob| (blob.namespace.as_str(), blob.id.as_str()))
200        .collect::<std::collections::BTreeSet<_>>();
201    let mut taken = Vec::new();
202    for (namespace, blob_id) in blob_keys {
203        let removed = crate::with_coven_sql_authority(|| {
204            conn.execute(
205                "DELETE FROM local_cleanup_intents
206                 WHERE namespace = ?1 AND blob_id = ?2 AND copy_identity = 'local'
207                   AND (
208                       EXISTS (
209                           SELECT 1 FROM store_write_blob_leases
210                           WHERE namespace = ?1 AND blob_id = ?2
211                       ) OR EXISTS (
212                           SELECT 1 FROM retained_replay_blob_leases
213                           WHERE namespace = ?1 AND blob_id = ?2
214                       )
215                   )",
216                (namespace, blob_id),
217            )
218            .map_err(DbError::from)
219        })?;
220        match removed {
221            0 => {}
222            1 => taken.push(LocalBlobCleanupIntent::local(namespace, blob_id)),
223            count => {
224                return Err(DbError::Message(format!(
225                "local cleanup restoration removed {count} obligations for {namespace}/{blob_id}"
226            )))
227            }
228        }
229    }
230    let published = super::blob_outbox::take_leased_published_blob_drop_intents_for_restoration_on(
231        conn, blobs,
232    )?;
233    Ok(SuspendedBlobCleanup {
234        local: taken,
235        published,
236    })
237}
238
239/// Cancel a suspended obligation when the restored rows need its local source,
240/// or put it back when the completed replay still leaves the source obsolete.
241pub(crate) fn reevaluate_suspended_blob_cleanup_on(
242    conn: &rusqlite::Connection,
243    decls: &BlobDecls,
244    cleanup: &SuspendedBlobCleanup,
245) -> Result<(), DbError> {
246    for intent in &cleanup.local {
247        record_obsolete_copy_intents_on(conn, decls, intent)?;
248    }
249    for intent in &cleanup.published {
250        let local_referenced = decls
251            .local_copy_is_referenced(conn, &intent.drop.namespace, &intent.drop.id)
252            .map_err(DbError::from)?;
253        if !local_referenced {
254            super::blob_outbox::reinsert_published_blob_drop_intent_on(conn, intent)?;
255        }
256    }
257    Ok(())
258}
259
260pub(crate) fn local_blob_cleanup_intents_on(
261    conn: &rusqlite::Connection,
262) -> Result<Vec<(LocalBlobCleanupIntent, bool)>, DbError> {
263    let mut statement = conn
264        .prepare(
265            "SELECT intent.namespace, intent.blob_id, intent.copy_identity, EXISTS (
266                     SELECT 1 FROM store_write_blob_leases lease
267                     WHERE lease.namespace = intent.namespace
268                       AND lease.blob_id = intent.blob_id
269                       AND intent.copy_identity = 'local'
270                 )
271                     OR EXISTS (
272                         SELECT 1 FROM retained_replay_blob_leases baseline
273                         WHERE baseline.namespace = intent.namespace
274                           AND baseline.blob_id = intent.blob_id
275                           AND intent.copy_identity = 'local'
276                     )
277                 FROM local_cleanup_intents intent
278                 ORDER BY namespace, blob_id,
279                          CASE WHEN copy_identity = 'local' THEN 1 ELSE 0 END,
280                          copy_identity",
281        )
282        .map_err(DbError::from)?;
283    let rows = statement
284        .query_map([], |row| {
285            Ok((
286                LocalBlobCleanupIntent::from_persisted(
287                    row.get::<_, String>(0)?,
288                    row.get::<_, String>(1)?,
289                    row.get::<_, String>(2)?,
290                )
291                .map_err(|error| {
292                    rusqlite::Error::FromSqlConversionFailure(
293                        2,
294                        rusqlite::types::Type::Text,
295                        Box::new(error),
296                    )
297                })?,
298                row.get::<_, bool>(3)?,
299            ))
300        })
301        .map_err(DbError::from)?;
302    rows.collect::<Result<Vec<_>, _>>().map_err(DbError::from)
303}
304
305pub(crate) fn complete_local_blob_cleanup_on(
306    conn: &rusqlite::Connection,
307    namespace: &str,
308    blob_id: &str,
309    persisted_identity: &str,
310) -> Result<(), DbError> {
311    conn.execute(
312        "DELETE FROM local_cleanup_intents
313                 WHERE namespace = ?1 AND blob_id = ?2 AND copy_identity = ?3",
314        (namespace, blob_id, persisted_identity),
315    )
316    .map(|_| ())
317    .map_err(DbError::from)
318}
319
320pub struct LocalBlobCleanup<'operation> {
321    database: &'operation StoreDatabase,
322}
323
324impl<'operation> LocalBlobCleanup<'operation> {
325    pub fn new(database: &'operation StoreDatabase) -> Self {
326        Self { database }
327    }
328
329    /// Drain every committed cleanup obligation. A filesystem or database
330    /// failure leaves the intent durable and fails the operation. `true` means
331    /// every remaining intent is blocked by an active Store-write lease.
332    pub async fn drain(&self) -> Result<bool, DbError> {
333        let database = self.database;
334        #[cfg(any(test, feature = "test-utils"))]
335        database
336            .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupRequested)
337            .await;
338        let _cleanup_guard = database.local_blob_cleanup_permit().await;
339        #[cfg(any(test, feature = "test-utils"))]
340        database
341            .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupAcquired)
342            .await;
343
344        let intents = database
345            .call_database(|session| session.local_blob_cleanup_intents())
346            .await?;
347
348        let mut pending = false;
349        for (intent, leased) in intents {
350            if leased {
351                pending = true;
352                debug!(
353                    namespace = %intent.namespace(),
354                    blob_id = %intent.blob_id(),
355                    "local blob cleanup is blocked by an active Store-write lease"
356                );
357                continue;
358            }
359            #[cfg(any(test, feature = "test-utils"))]
360            database
361                .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupBeforeFilesystem {
362                    namespace: intent.namespace().to_string(),
363                    blob_id: intent.blob_id().to_string(),
364                })
365                .await;
366            let persisted_identity = intent.persisted_identity()?;
367            database.apply_local_blob_cleanup_intent(&intent).await?;
368
369            let namespace = intent.namespace().to_string();
370            let blob_id = intent.blob_id().to_string();
371            database
372                .call_database(move |session| {
373                    session.complete_local_blob_cleanup(&namespace, &blob_id, &persisted_identity)
374                })
375                .await?;
376        }
377        #[cfg(any(test, feature = "test-utils"))]
378        database
379            .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupFinished)
380            .await;
381        Ok(pending)
382    }
383}
384
385#[cfg(test)]
386impl StoreSession<'_> {
387    fn record_obsolete_copy_intent_for_test(
388        &self,
389        intent: &LocalBlobCleanupIntent,
390    ) -> Result<(), DbError> {
391        record_obsolete_copy_intents_on(self.conn, self.blob_decls, intent)
392    }
393}
394
395#[cfg(test)]
396impl StoreDatabase {
397    async fn record_obsolete_copy_intent_for_test(
398        &self,
399        intent: LocalBlobCleanupIntent,
400    ) -> Result<(), DbError> {
401        self.call_store(move |session| session.record_obsolete_copy_intent_for_test(&intent))
402            .await
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::synthetic_store::open_test_db_with_blob;
410    use coven_protocol::blob::{CacheFill, Provenance};
411    use coven_protocol::store_commit::ObjectHash;
412    use coven_protocol::synced_schema::BlobDecl;
413
414    #[tokio::test]
415    async fn a_live_same_id_row_with_another_locator_does_not_suppress_exact_cleanup() {
416        let store_dir = crate::synthetic_store::test_store_dir();
417        let db = open_test_db_with_blob(
418            store_dir,
419            BlobDecl::new("photos", Provenance::HostProvided, CacheFill::CacheEager)
420                .with_id_column("blob_id"),
421        );
422        let removed_locator = ObjectHash::digest(b"removed locator");
423        let live_locator = ObjectHash::digest(b"live locator");
424        let removed_object = ObjectHash::digest(b"removed object");
425        let live_object = ObjectHash::digest(b"live object");
426        let database = StoreDatabase::new(&db);
427
428        db.seed_distinct_cleanup_bindings_for_test(
429            removed_locator,
430            live_locator,
431            removed_object,
432            live_object,
433        )
434        .await
435        .expect("seed removed and live blob bindings");
436
437        database
438            .record_obsolete_copy_intent_for_test(LocalBlobCleanupIntent::for_row(
439                "photos",
440                "shared-id",
441                "note_photos",
442                "removed-row",
443            ))
444            .await
445            .expect("record obsolete row copies");
446
447        db.cleanup_intent_copy_identities_for_test()
448            .await
449            .map(|identities| {
450                assert_eq!(
451                    identities,
452                    [removed_locator.to_string(), "local".to_string()]
453                );
454            })
455            .expect("record exact cleanup despite a live same-id row");
456    }
457}