Skip to main content

shardline_server/
lifecycle_repair.rs

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
23/// Default retention for processed webhook delivery claims before repair prunes them.
24pub const DEFAULT_WEBHOOK_DELIVERY_RETENTION_SECONDS: u64 = 2_592_000;
25
26pub(crate) const WEBHOOK_DELIVERY_FUTURE_SKEW_SECONDS: u64 = 300;
27
28/// Lifecycle-repair execution options.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct LifecycleRepairOptions {
31    /// Retention applied to processed webhook-delivery claims before they become repairable.
32    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/// Lifecycle-repair report.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LifecycleRepairReport {
46    /// Number of file and file-version records scanned to derive reachability.
47    pub scanned_records: u64,
48    /// Number of distinct object keys found reachable from current live metadata.
49    pub referenced_objects: u64,
50    /// Number of quarantine candidates inspected.
51    pub scanned_quarantine_candidates: u64,
52    /// Number of quarantine candidates removed because the object was already missing.
53    pub removed_missing_quarantine_candidates: u64,
54    /// Number of quarantine candidates removed because the object was reachable again.
55    pub removed_reachable_quarantine_candidates: u64,
56    /// Number of quarantine candidates removed because an active hold protected the object.
57    pub removed_held_quarantine_candidates: u64,
58    /// Number of retention holds inspected.
59    pub scanned_retention_holds: u64,
60    /// Number of expired retention holds removed.
61    pub removed_expired_retention_holds: u64,
62    /// Number of retention holds removed because the protected object was already missing.
63    pub removed_missing_retention_holds: u64,
64    /// Number of webhook delivery claims inspected.
65    pub scanned_webhook_deliveries: u64,
66    /// Number of webhook delivery claims removed because they were older than the configured retention.
67    pub removed_stale_webhook_deliveries: u64,
68    /// Number of webhook delivery claims removed because they were far in the future.
69    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
101/// Repairs stale lifecycle metadata against the configured metadata backend.
102///
103/// # Errors
104///
105/// Returns [`ServerError`] when metadata cannot be scanned or updated.
106pub 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
137/// Repairs stale lifecycle metadata for one local storage root.
138///
139/// # Errors
140///
141/// Returns [`ServerError`] when metadata cannot be scanned or updated.
142pub 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;