1use std::{collections::HashSet, path::PathBuf};
2
3use shardline_index::{
4 AsyncIndexStore, FileRecordStorageLayout, LocalIndexStore, PostgresIndexStore,
5 PostgresRecordStore, RecordStore, RecordTraversal, xet_hash_hex_string,
6};
7use shardline_storage::ObjectStore;
8
9use crate::{
10 ServerConfig, ServerError, ServerFrontend,
11 chunk_store::chunk_object_key,
12 clock::unix_now_seconds_checked,
13 object_store::{ServerObjectStore, object_store_from_config},
14 overflow::checked_increment,
15 postgres_backend::connect_postgres_metadata_pool,
16 record_store::{LocalRecordStore, parse_stored_file_record_bytes},
17 server_frontend::{
18 optional_chunk_container_keys, referenced_term_object_key,
19 visit_protocol_object_member_chunks,
20 },
21};
22
23pub const DEFAULT_WEBHOOK_DELIVERY_RETENTION_SECONDS: u64 = 2_592_000;
25
26pub(crate) const WEBHOOK_DELIVERY_FUTURE_SKEW_SECONDS: u64 = 300;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct LifecycleRepairOptions {
31 pub webhook_retention_seconds: u64,
33}
34
35impl Default for LifecycleRepairOptions {
36 fn default() -> Self {
37 Self {
38 webhook_retention_seconds: DEFAULT_WEBHOOK_DELIVERY_RETENTION_SECONDS,
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LifecycleRepairReport {
46 pub scanned_records: u64,
48 pub referenced_objects: u64,
50 pub scanned_quarantine_candidates: u64,
52 pub removed_missing_quarantine_candidates: u64,
54 pub removed_reachable_quarantine_candidates: u64,
56 pub removed_held_quarantine_candidates: u64,
58 pub scanned_retention_holds: u64,
60 pub removed_expired_retention_holds: u64,
62 pub removed_missing_retention_holds: u64,
64 pub scanned_webhook_deliveries: u64,
66 pub removed_stale_webhook_deliveries: u64,
68 pub removed_future_webhook_deliveries: u64,
70}
71
72#[derive(Debug, Default)]
73struct RepairReachability {
74 referenced_object_keys: HashSet<String>,
75 live_dedupe_chunk_hashes: HashSet<String>,
76 scanned_records: u64,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub(crate) enum QuarantineRepairAction {
81 Keep,
82 DeleteMissing,
83 DeleteReachable,
84 DeleteHeld,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub(crate) enum RetentionHoldRepairAction {
89 Keep,
90 DeleteExpired,
91 DeleteMissing,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub(crate) enum WebhookDeliveryRepairAction {
96 Keep,
97 DeleteStale,
98 DeleteFuture,
99}
100
101pub async fn run_lifecycle_repair(
107 config: ServerConfig,
108 options: LifecycleRepairOptions,
109) -> Result<LifecycleRepairReport, ServerError> {
110 let object_store = object_store_from_config(&config)?;
111 if let Some(index_postgres_url) = config.index_postgres_url() {
112 let pool = connect_postgres_metadata_pool(index_postgres_url, 4)?;
113 let index_store = PostgresIndexStore::new(pool.clone());
114 let record_store = PostgresRecordStore::new(pool);
115 return run_lifecycle_repair_with_stores(
116 &record_store,
117 &index_store,
118 &object_store,
119 config.server_frontends(),
120 options,
121 )
122 .await;
123 }
124
125 let index_store = LocalIndexStore::open(config.root_dir().to_path_buf());
126 let record_store = LocalRecordStore::open(config.root_dir().to_path_buf());
127 run_lifecycle_repair_with_stores(
128 &record_store,
129 &index_store,
130 &object_store,
131 config.server_frontends(),
132 options,
133 )
134 .await
135}
136
137pub async fn run_local_lifecycle_repair(
143 root: PathBuf,
144 options: LifecycleRepairOptions,
145) -> Result<LifecycleRepairReport, ServerError> {
146 let object_store = ServerObjectStore::local(root.join("chunks"))?;
147 let index_store = LocalIndexStore::open(root.clone());
148 let record_store = LocalRecordStore::open(root);
149 run_lifecycle_repair_with_stores(
150 &record_store,
151 &index_store,
152 &object_store,
153 &[ServerFrontend::Xet],
154 options,
155 )
156 .await
157}
158
159async fn run_lifecycle_repair_with_stores<RecordAdapter, IndexAdapter>(
160 record_store: &RecordAdapter,
161 index_store: &IndexAdapter,
162 object_store: &ServerObjectStore,
163 frontends: &[ServerFrontend],
164 options: LifecycleRepairOptions,
165) -> Result<LifecycleRepairReport, ServerError>
166where
167 RecordAdapter: RecordStore + Sync,
168 RecordAdapter::Error: Into<ServerError>,
169 IndexAdapter: AsyncIndexStore + Sync,
170 IndexAdapter::Error: Into<ServerError>,
171{
172 let now_unix_seconds = unix_now_seconds_checked()?;
173 run_lifecycle_repair_with_stores_at_time(
174 record_store,
175 index_store,
176 object_store,
177 frontends,
178 options,
179 now_unix_seconds,
180 )
181 .await
182}
183
184async fn run_lifecycle_repair_with_stores_at_time<RecordAdapter, IndexAdapter>(
185 record_store: &RecordAdapter,
186 index_store: &IndexAdapter,
187 object_store: &ServerObjectStore,
188 frontends: &[ServerFrontend],
189 options: LifecycleRepairOptions,
190 now_unix_seconds: u64,
191) -> Result<LifecycleRepairReport, ServerError>
192where
193 RecordAdapter: RecordStore + Sync,
194 RecordAdapter::Error: Into<ServerError>,
195 IndexAdapter: AsyncIndexStore + Sync,
196 IndexAdapter::Error: Into<ServerError>,
197{
198 let mut reachability = RepairReachability::default();
199 collect_referenced_object_keys(
200 record_store,
201 index_store,
202 object_store,
203 frontends,
204 &mut reachability,
205 )
206 .await?;
207
208 let max_processed_at_unix_seconds = now_unix_seconds
209 .checked_add(WEBHOOK_DELIVERY_FUTURE_SKEW_SECONDS)
210 .ok_or(ServerError::Overflow)?;
211 let stale_webhook_cutoff = now_unix_seconds.saturating_sub(options.webhook_retention_seconds);
212
213 let mut report = LifecycleRepairReport {
214 scanned_records: reachability.scanned_records,
215 referenced_objects: u64::try_from(reachability.referenced_object_keys.len())?,
216 scanned_quarantine_candidates: 0,
217 removed_missing_quarantine_candidates: 0,
218 removed_reachable_quarantine_candidates: 0,
219 removed_held_quarantine_candidates: 0,
220 scanned_retention_holds: 0,
221 removed_expired_retention_holds: 0,
222 removed_missing_retention_holds: 0,
223 scanned_webhook_deliveries: 0,
224 removed_stale_webhook_deliveries: 0,
225 removed_future_webhook_deliveries: 0,
226 };
227
228 let retention_holds = index_store
229 .list_retention_holds()
230 .await
231 .map_err(Into::into)?;
232 let mut active_hold_object_keys = HashSet::new();
233 for hold in retention_holds {
234 report.scanned_retention_holds = checked_increment(report.scanned_retention_holds)?;
235 match classify_retention_hold_repair_action(
236 hold.release_after_unix_seconds(),
237 hold.held_at_unix_seconds(),
238 object_store.metadata(hold.object_key())?.is_some(),
239 now_unix_seconds,
240 ) {
241 RetentionHoldRepairAction::Keep => {
242 active_hold_object_keys.insert(hold.object_key().as_str().to_owned());
243 }
244 RetentionHoldRepairAction::DeleteExpired => {
245 let _deleted = index_store
246 .delete_retention_hold(hold.object_key())
247 .await
248 .map_err(Into::into)?;
249 report.removed_expired_retention_holds =
250 checked_increment(report.removed_expired_retention_holds)?;
251 }
252 RetentionHoldRepairAction::DeleteMissing => {
253 let _deleted = index_store
254 .delete_retention_hold(hold.object_key())
255 .await
256 .map_err(Into::into)?;
257 report.removed_missing_retention_holds =
258 checked_increment(report.removed_missing_retention_holds)?;
259 }
260 }
261 }
262
263 let quarantine_candidates = index_store
264 .list_quarantine_candidates()
265 .await
266 .map_err(Into::into)?;
267 for candidate in quarantine_candidates {
268 report.scanned_quarantine_candidates =
269 checked_increment(report.scanned_quarantine_candidates)?;
270 let object_key = candidate.object_key();
271 let object_exists = object_store.metadata(object_key)?.is_some();
272 let action = classify_quarantine_repair_action(
273 object_exists,
274 reachability
275 .referenced_object_keys
276 .contains(candidate.object_key().as_str()),
277 active_hold_object_keys.contains(candidate.object_key().as_str()),
278 );
279 match action {
280 QuarantineRepairAction::Keep => {}
281 QuarantineRepairAction::DeleteMissing => {
282 let _deleted = index_store
283 .delete_quarantine_candidate(object_key)
284 .await
285 .map_err(Into::into)?;
286 report.removed_missing_quarantine_candidates =
287 checked_increment(report.removed_missing_quarantine_candidates)?;
288 }
289 QuarantineRepairAction::DeleteReachable => {
290 let _deleted = index_store
291 .delete_quarantine_candidate(object_key)
292 .await
293 .map_err(Into::into)?;
294 report.removed_reachable_quarantine_candidates =
295 checked_increment(report.removed_reachable_quarantine_candidates)?;
296 }
297 QuarantineRepairAction::DeleteHeld => {
298 let _deleted = index_store
299 .delete_quarantine_candidate(object_key)
300 .await
301 .map_err(Into::into)?;
302 report.removed_held_quarantine_candidates =
303 checked_increment(report.removed_held_quarantine_candidates)?;
304 }
305 }
306 }
307
308 let webhook_deliveries = index_store
309 .list_webhook_deliveries()
310 .await
311 .map_err(Into::into)?;
312 for delivery in webhook_deliveries {
313 report.scanned_webhook_deliveries = checked_increment(report.scanned_webhook_deliveries)?;
314 match classify_webhook_delivery_repair_action(
315 delivery.processed_at_unix_seconds(),
316 stale_webhook_cutoff,
317 max_processed_at_unix_seconds,
318 ) {
319 WebhookDeliveryRepairAction::Keep => {}
320 WebhookDeliveryRepairAction::DeleteStale => {
321 let _deleted = index_store
322 .delete_webhook_delivery(&delivery)
323 .await
324 .map_err(Into::into)?;
325 report.removed_stale_webhook_deliveries =
326 checked_increment(report.removed_stale_webhook_deliveries)?;
327 }
328 WebhookDeliveryRepairAction::DeleteFuture => {
329 let _deleted = index_store
330 .delete_webhook_delivery(&delivery)
331 .await
332 .map_err(Into::into)?;
333 report.removed_future_webhook_deliveries =
334 checked_increment(report.removed_future_webhook_deliveries)?;
335 }
336 }
337 }
338
339 Ok(report)
340}
341
342async fn collect_referenced_object_keys<RecordAdapter, IndexAdapter>(
343 record_store: &RecordAdapter,
344 index_store: &IndexAdapter,
345 object_store: &ServerObjectStore,
346 frontends: &[ServerFrontend],
347 reachability: &mut RepairReachability,
348) -> Result<(), ServerError>
349where
350 RecordAdapter: RecordStore + Sync,
351 RecordAdapter::Error: Into<ServerError>,
352 IndexAdapter: AsyncIndexStore + Sync,
353 IndexAdapter::Error: Into<ServerError>,
354{
355 RecordTraversal::visit_latest_records(record_store, |entry| {
356 collect_record_object_references(object_store, frontends, &entry.bytes, reachability)
357 })
358 .await?;
359
360 RecordTraversal::visit_version_records(record_store, |entry| {
361 collect_record_object_references(object_store, frontends, &entry.bytes, reachability)
362 })
363 .await?;
364
365 index_store
366 .visit_dedupe_shard_mappings(|mapping| {
367 let chunk_hash_hex = xet_hash_hex_string(mapping.chunk_hash());
368 if reachability
369 .live_dedupe_chunk_hashes
370 .contains(&chunk_hash_hex)
371 {
372 reachability
373 .referenced_object_keys
374 .insert(mapping.shard_object_key().as_str().to_owned());
375 }
376 Ok::<(), ServerError>(())
377 })
378 .await?;
379
380 Ok(())
381}
382
383fn collect_record_object_references(
384 object_store: &ServerObjectStore,
385 frontends: &[ServerFrontend],
386 record_bytes: &[u8],
387 reachability: &mut RepairReachability,
388) -> Result<(), ServerError> {
389 let record = parse_stored_file_record_bytes(record_bytes)?;
390 let storage_layout = record.storage_layout();
391 reachability.scanned_records = checked_increment(reachability.scanned_records)?;
392 for chunk in &record.chunks {
393 match storage_layout {
394 FileRecordStorageLayout::ReferencedObjectTerms => {
395 let protocol_object_key = referenced_term_object_key(frontends, &chunk.hash)?;
396 reachability
397 .referenced_object_keys
398 .insert(protocol_object_key.as_str().to_owned());
399 visit_protocol_object_member_chunks(
400 frontends,
401 object_store,
402 &protocol_object_key,
403 |chunk_hash_hex| {
404 let chunk_key = chunk_object_key(&chunk_hash_hex)?;
405 reachability
406 .referenced_object_keys
407 .insert(chunk_key.as_str().to_owned());
408 Ok(())
409 },
410 )?;
411 }
412 FileRecordStorageLayout::StoredChunks => {
413 let chunk_key = chunk_object_key(&chunk.hash)?;
414 reachability
415 .referenced_object_keys
416 .insert(chunk_key.as_str().to_owned());
417 reachability
418 .live_dedupe_chunk_hashes
419 .insert(chunk.hash.clone());
420
421 for protocol_object_key in optional_chunk_container_keys(frontends, &chunk.hash)? {
422 if object_store.metadata(&protocol_object_key)?.is_some() {
423 reachability
424 .referenced_object_keys
425 .insert(protocol_object_key.as_str().to_owned());
426 }
427 }
428 }
429 }
430 }
431
432 Ok(())
433}
434
435pub(crate) const fn classify_quarantine_repair_action(
436 object_exists: bool,
437 is_reachable: bool,
438 is_held: bool,
439) -> QuarantineRepairAction {
440 if !object_exists {
441 return QuarantineRepairAction::DeleteMissing;
442 }
443 if is_reachable {
444 return QuarantineRepairAction::DeleteReachable;
445 }
446 if is_held {
447 return QuarantineRepairAction::DeleteHeld;
448 }
449 QuarantineRepairAction::Keep
450}
451
452pub(crate) const fn classify_retention_hold_repair_action(
453 release_after_unix_seconds: Option<u64>,
454 held_at_unix_seconds: u64,
455 object_exists: bool,
456 now_unix_seconds: u64,
457) -> RetentionHoldRepairAction {
458 if let Some(release_after_unix_seconds) = release_after_unix_seconds
459 && (release_after_unix_seconds < held_at_unix_seconds
460 || release_after_unix_seconds <= now_unix_seconds)
461 {
462 return RetentionHoldRepairAction::DeleteExpired;
463 }
464 if !object_exists {
465 return RetentionHoldRepairAction::DeleteMissing;
466 }
467 RetentionHoldRepairAction::Keep
468}
469
470pub(crate) const fn classify_webhook_delivery_repair_action(
471 processed_at_unix_seconds: u64,
472 stale_cutoff_unix_seconds: u64,
473 max_processed_at_unix_seconds: u64,
474) -> WebhookDeliveryRepairAction {
475 if processed_at_unix_seconds > max_processed_at_unix_seconds {
476 return WebhookDeliveryRepairAction::DeleteFuture;
477 }
478 if processed_at_unix_seconds <= stale_cutoff_unix_seconds {
479 return WebhookDeliveryRepairAction::DeleteStale;
480 }
481 WebhookDeliveryRepairAction::Keep
482}
483
484#[cfg(test)]
485mod tests;