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
48pub(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
190pub(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
239pub(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 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}