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    suspend_blob_cleanup_for_restoration_on(conn, blobs, |_, leased| Ok(leased))
198}
199
200/// The selection owner must prove either that a lease prevents deletion or
201/// that the installed rows never reference the copy being deleted. A filesystem
202/// drain may already hold an intent, so suspending its SQL guard cannot cancel
203/// an unleased deletion.
204pub(super) fn suspend_blob_cleanup_for_restoration_on(
205    conn: &rusqlite::Connection,
206    blobs: &[coven_protocol::blob::BlobRef],
207    can_restore: impl Fn(&LocalBlobCleanupIntent, bool) -> Result<bool, DbError>,
208) -> Result<SuspendedBlobCleanup, DbError> {
209    let blob_keys = blobs
210        .iter()
211        .map(|blob| (blob.namespace.as_str(), blob.id.as_str()))
212        .collect::<std::collections::BTreeSet<_>>();
213    let mut taken = Vec::new();
214    for (intent, leased) in local_blob_cleanup_intents_on(conn)? {
215        let namespace = intent.namespace();
216        let blob_id = intent.blob_id();
217        if !blob_keys.contains(&(namespace, blob_id)) || !can_restore(&intent, leased)? {
218            continue;
219        }
220        let removed = crate::with_coven_sql_authority(|| {
221            conn.execute(
222                "DELETE FROM local_cleanup_intents
223                 WHERE namespace = ?1 AND blob_id = ?2 AND copy_identity = ?3",
224                (namespace, blob_id, intent.persisted_identity()?),
225            )
226            .map_err(DbError::from)
227        })?;
228        match removed {
229            1 => taken.push(intent),
230            count => {
231                return Err(DbError::Message(format!(
232                    "local cleanup restoration removed {count} obligations for {namespace}/{blob_id}"
233                )));
234            }
235        }
236    }
237    let published = super::blob_outbox::take_published_blob_drop_intents_for_restoration_on(
238        conn,
239        blobs,
240        |intent, leased| {
241            can_restore(
242                &LocalBlobCleanupIntent::local(&intent.drop.namespace, &intent.drop.id),
243                leased,
244            )
245        },
246    )?;
247    Ok(SuspendedBlobCleanup {
248        local: taken,
249        published,
250    })
251}
252
253/// Cancel a suspended obligation when the restored rows need its local source,
254/// or put it back when the completed replay still leaves the source obsolete.
255pub(crate) fn reevaluate_suspended_blob_cleanup_on(
256    conn: &rusqlite::Connection,
257    decls: &BlobDecls,
258    cleanup: &SuspendedBlobCleanup,
259) -> Result<(), DbError> {
260    for intent in &cleanup.local {
261        if let LocalBlobCleanupIdentity::Exact(hash) = intent.identity() {
262            if decls.exact_copy_is_referenced(conn, intent.namespace(), intent.blob_id(), *hash)? {
263                return Err(DbError::Message(
264                    "restored projection references a suspended exact blob cleanup copy".into(),
265                ));
266            }
267            record_durable_intent(conn, intent)?;
268        } else {
269            record_obsolete_copy_intents_on(conn, decls, intent)?;
270        }
271    }
272    for intent in &cleanup.published {
273        let local_referenced = decls
274            .local_copy_is_referenced(conn, &intent.drop.namespace, &intent.drop.id)
275            .map_err(DbError::from)?;
276        if !local_referenced {
277            super::blob_outbox::reinsert_published_blob_drop_intent_on(conn, intent)?;
278        }
279    }
280    Ok(())
281}
282
283pub(crate) fn local_blob_cleanup_intents_on(
284    conn: &rusqlite::Connection,
285) -> Result<Vec<(LocalBlobCleanupIntent, bool)>, DbError> {
286    let mut statement = conn
287        .prepare(
288            "SELECT intent.namespace, intent.blob_id, intent.copy_identity, EXISTS (
289                     SELECT 1 FROM store_write_blob_leases lease
290                     WHERE lease.namespace = intent.namespace
291                       AND lease.blob_id = intent.blob_id
292                       AND intent.copy_identity = 'local'
293                 )
294                     OR EXISTS (
295                         SELECT 1 FROM retained_replay_blob_leases baseline
296                         WHERE baseline.namespace = intent.namespace
297                           AND baseline.blob_id = intent.blob_id
298                           AND intent.copy_identity = 'local'
299                     )
300                 FROM local_cleanup_intents intent
301                 ORDER BY namespace, blob_id,
302                          CASE WHEN copy_identity = 'local' THEN 1 ELSE 0 END,
303                          copy_identity",
304        )
305        .map_err(DbError::from)?;
306    let rows = statement
307        .query_map([], |row| {
308            Ok((
309                LocalBlobCleanupIntent::from_persisted(
310                    row.get::<_, String>(0)?,
311                    row.get::<_, String>(1)?,
312                    row.get::<_, String>(2)?,
313                )
314                .map_err(|error| {
315                    rusqlite::Error::FromSqlConversionFailure(
316                        2,
317                        rusqlite::types::Type::Text,
318                        Box::new(error),
319                    )
320                })?,
321                row.get::<_, bool>(3)?,
322            ))
323        })
324        .map_err(DbError::from)?;
325    rows.collect::<Result<Vec<_>, _>>().map_err(DbError::from)
326}
327
328pub(crate) fn complete_local_blob_cleanup_on(
329    conn: &rusqlite::Connection,
330    namespace: &str,
331    blob_id: &str,
332    persisted_identity: &str,
333) -> Result<(), DbError> {
334    conn.execute(
335        "DELETE FROM local_cleanup_intents
336                 WHERE namespace = ?1 AND blob_id = ?2 AND copy_identity = ?3",
337        (namespace, blob_id, persisted_identity),
338    )
339    .map(|_| ())
340    .map_err(DbError::from)
341}
342
343pub struct LocalBlobCleanup<'operation> {
344    database: &'operation StoreDatabase,
345}
346
347impl<'operation> LocalBlobCleanup<'operation> {
348    pub fn new(database: &'operation StoreDatabase) -> Self {
349        Self { database }
350    }
351
352    /// Drain every committed cleanup obligation. A filesystem or database
353    /// failure leaves the intent durable and fails the operation. `true` means
354    /// every remaining intent is blocked by an active Store-write lease.
355    pub async fn drain(&self) -> Result<bool, DbError> {
356        let database = self.database;
357        #[cfg(any(test, feature = "test-utils"))]
358        database
359            .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupRequested)
360            .await;
361        let _cleanup_guard = database.local_blob_cleanup_permit().await;
362        #[cfg(any(test, feature = "test-utils"))]
363        database
364            .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupAcquired)
365            .await;
366
367        let intents = database
368            .call_database(|session| session.local_blob_cleanup_intents())
369            .await?;
370
371        let mut pending = false;
372        for (intent, leased) in intents {
373            if leased {
374                pending = true;
375                debug!(
376                    namespace = %intent.namespace(),
377                    blob_id = %intent.blob_id(),
378                    "local blob cleanup is blocked by an active Store-write lease"
379                );
380                continue;
381            }
382            #[cfg(any(test, feature = "test-utils"))]
383            database
384                .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupBeforeFilesystem {
385                    namespace: intent.namespace().to_string(),
386                    blob_id: intent.blob_id().to_string(),
387                })
388                .await;
389            let persisted_identity = intent.persisted_identity()?;
390            database.apply_local_blob_cleanup_intent(&intent).await?;
391
392            let namespace = intent.namespace().to_string();
393            let blob_id = intent.blob_id().to_string();
394            database
395                .call_database(move |session| {
396                    session.complete_local_blob_cleanup(&namespace, &blob_id, &persisted_identity)
397                })
398                .await?;
399        }
400        #[cfg(any(test, feature = "test-utils"))]
401        database
402            .reach_test_point(crate::DatabaseTestPoint::LocalBlobCleanupFinished)
403            .await;
404        Ok(pending)
405    }
406}
407
408#[cfg(test)]
409impl StoreSession<'_> {
410    fn record_obsolete_copy_intent_for_test(
411        &self,
412        intent: &LocalBlobCleanupIntent,
413    ) -> Result<(), DbError> {
414        record_obsolete_copy_intents_on(self.conn, self.blob_decls, intent)
415    }
416}
417
418#[cfg(test)]
419impl StoreDatabase {
420    async fn record_obsolete_copy_intent_for_test(
421        &self,
422        intent: LocalBlobCleanupIntent,
423    ) -> Result<(), DbError> {
424        self.call_store(move |session| session.record_obsolete_copy_intent_for_test(&intent))
425            .await
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::synthetic_store::open_test_db_with_blob;
433    use coven_protocol::blob::{CacheFill, Provenance};
434    use coven_protocol::store_commit::ObjectHash;
435    use coven_protocol::synced_schema::BlobDecl;
436
437    #[tokio::test]
438    async fn a_live_same_id_row_with_another_locator_does_not_suppress_exact_cleanup() {
439        let store_dir = crate::synthetic_store::test_store_dir();
440        let db = open_test_db_with_blob(
441            store_dir,
442            BlobDecl::new("photos", Provenance::HostProvided, CacheFill::CacheEager)
443                .with_id_column("blob_id"),
444        );
445        let removed_locator = ObjectHash::digest(b"removed locator");
446        let live_locator = ObjectHash::digest(b"live locator");
447        let removed_object = ObjectHash::digest(b"removed object");
448        let live_object = ObjectHash::digest(b"live object");
449        let database = StoreDatabase::new(&db);
450
451        db.seed_distinct_cleanup_bindings_for_test(
452            removed_locator,
453            live_locator,
454            removed_object,
455            live_object,
456        )
457        .await
458        .expect("seed removed and live blob bindings");
459
460        database
461            .record_obsolete_copy_intent_for_test(LocalBlobCleanupIntent::for_row(
462                "photos",
463                "shared-id",
464                "note_photos",
465                "removed-row",
466            ))
467            .await
468            .expect("record obsolete row copies");
469
470        db.cleanup_intent_copy_identities_for_test()
471            .await
472            .map(|identities| {
473                assert_eq!(
474                    identities,
475                    [removed_locator.to_string(), "local".to_string()]
476                );
477            })
478            .expect("record exact cleanup despite a live same-id row");
479    }
480}