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 suspend_blob_cleanup_for_restoration_on(conn, blobs, |_, leased| Ok(leased))
198}
199
200pub(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
253pub(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 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}