Skip to main content

zeppelin_embed/ingest/
retention.rs

1//! Whole-segment partition retention.
2
3use std::ops::Range;
4use std::sync::Arc;
5
6use crate::lifecycle::durability::SyncRequirement;
7use crate::lifecycle::{PublishedSnapshot, Store, StoreError, StoreState};
8use crate::manifest::io::{MANIFEST_FILE, load_manifest};
9use crate::segment::{ClusteringKeyRange, SegmentId, SegmentMeta};
10use crate::vfs::Vfs;
11
12/// Pure timestamp-window policy evaluated only when a host calls it.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub struct RetentionPolicy {
15    window: i64,
16}
17
18/// Typed rejection of a non-positive retention window.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct RetentionPolicyError {
21    window: i64,
22}
23
24impl std::fmt::Display for RetentionPolicyError {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(
27            formatter,
28            "retention window {} must be positive",
29            self.window
30        )
31    }
32}
33
34impl std::error::Error for RetentionPolicyError {}
35
36impl RetentionPolicy {
37    /// Creates a positive retention window in the caller's timestamp unit.
38    pub const fn new(window: i64) -> Result<Self, RetentionPolicyError> {
39        if window <= 0 {
40            Err(RetentionPolicyError { window })
41        } else {
42            Ok(Self { window })
43        }
44    }
45
46    /// Purely decides the half-open old-key range outside the live window.
47    #[must_use]
48    pub const fn partition_to_drop(self, now_ts: i64) -> Range<i64> {
49        i64::MIN..now_ts.saturating_sub(self.window)
50    }
51}
52
53/// Result of one explicit whole-segment partition drop.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct DropPartitionReport {
56    generation: u64,
57    manifest_committed: bool,
58    segments_dropped: Vec<SegmentId>,
59    bytes_reclaimed: u64,
60    orphaned_segments: Vec<SegmentId>,
61    straddlers_skipped: Vec<SegmentId>,
62}
63
64impl DropPartitionReport {
65    /// Returns the committed or unchanged generation observed by this call.
66    #[must_use]
67    pub const fn generation(&self) -> u64 {
68        self.generation
69    }
70
71    /// Returns whether no manifest commit and no unlink were needed.
72    #[must_use]
73    pub const fn is_no_op(&self) -> bool {
74        !self.manifest_committed
75    }
76
77    /// Returns immutable segments omitted by the committed manifest.
78    #[must_use]
79    pub fn segments_dropped(&self) -> &[SegmentId] {
80        &self.segments_dropped
81    }
82
83    /// Returns the exact immutable file bytes unlinked after commit.
84    #[must_use]
85    pub const fn bytes_reclaimed(&self) -> u64 {
86        self.bytes_reclaimed
87    }
88
89    /// Returns dropped segments whose post-commit unlink failed.
90    #[must_use]
91    pub fn orphaned_segments(&self) -> &[SegmentId] {
92        &self.orphaned_segments
93    }
94
95    /// Returns overlapping segments retained because they crossed a boundary.
96    #[must_use]
97    pub fn straddlers_skipped(&self) -> &[SegmentId] {
98        &self.straddlers_skipped
99    }
100}
101
102impl Store {
103    /// Drops immutable segments wholly contained by the half-open timestamp range.
104    pub fn drop_partition(&self, key_range: Range<i64>) -> Result<DropPartitionReport, StoreError> {
105        let retained_through = key_range.end;
106        let report = self.drop_partition_on_vfs(key_range, self.vfs.as_ref())?;
107        self.record_retention(retained_through)?;
108        Ok(report)
109    }
110
111    /// Evaluates a retention policy now and explicitly invokes partition drop.
112    pub fn apply_retention(
113        &self,
114        policy: RetentionPolicy,
115        now_ts: i64,
116    ) -> Result<DropPartitionReport, StoreError> {
117        let range = policy.partition_to_drop(now_ts);
118        #[cfg(any(test, feature = "test-seams"))]
119        let receipt_plan = match self.ingest_retention_fault_controller.as_ref() {
120            Some(controller) => controller
121                .retention_clock_boundary_plan(now_ts, policy.window, range.end)
122                .map_err(|_| StoreError::Synchronization {
123                    component: "ingest-retention controller",
124                })?,
125            None => None,
126        };
127        let report = self.drop_partition(range)?;
128        #[cfg(any(test, feature = "test-seams"))]
129        if let Some(invocation_id) = receipt_plan
130            && let Some(controller) = self.ingest_retention_fault_controller.as_ref()
131        {
132            let dropped_count = u64::try_from(report.segments_dropped.len())
133                .map_err(|_| StoreError::PartitionBytesOverflow)?;
134            let straddler_count = u64::try_from(report.straddlers_skipped.len())
135                .map_err(|_| StoreError::PartitionBytesOverflow)?;
136            controller
137                .push_receipt(
138                    super::IngestRetentionFaultReceiptV1::retention_clock_boundary(
139                        invocation_id,
140                        now_ts,
141                        policy.window,
142                        policy.partition_to_drop(now_ts).end,
143                        report.generation,
144                        dropped_count,
145                        straddler_count,
146                        report.manifest_committed,
147                    ),
148                )
149                .map_err(|_| StoreError::Synchronization {
150                    component: "ingest-retention controller",
151                })?;
152        }
153        Ok(report)
154    }
155
156    /// Test-support seam for counting filesystem work during partition drop.
157    #[doc(hidden)]
158    pub fn drop_partition_on_vfs(
159        &self,
160        key_range: Range<i64>,
161        vfs: &dyn Vfs,
162    ) -> Result<DropPartitionReport, StoreError> {
163        #[cfg(feature = "graph-cypher")]
164        {
165            self.with_native_document_recovery(
166                || self.drop_partition_attempt(key_range.clone(), vfs),
167                |error| matches!(error, StoreError::DocumentMutation(error) if error.is_native_reclaim_debt()),
168                |error| StoreError::DocumentMutation(Box::new(error)),
169            )
170        }
171        #[cfg(not(feature = "graph-cypher"))]
172        self.drop_partition_attempt(key_range, vfs)
173    }
174
175    fn drop_partition_attempt(
176        &self,
177        key_range: Range<i64>,
178        vfs: &dyn Vfs,
179    ) -> Result<DropPartitionReport, StoreError> {
180        let _data_read_audit =
181            crate::segment::reader::install_data_read_audit(vfs.segment_data_read_counter());
182        let _maintenance = self
183            .maintenance
184            .lock()
185            .map_err(|_| StoreError::Synchronization {
186                component: "partition retention",
187            })?;
188        #[cfg(feature = "graph-cypher")]
189        let mut native_writer = self.native_document_writer()?;
190        #[cfg(feature = "graph-cypher")]
191        if let Some(writer) = native_writer.as_mut().and_then(|slot| slot.as_mut()) {
192            let ids = {
193                let snapshot = self
194                    .snapshot
195                    .read()
196                    .map_err(|_| StoreError::Synchronization {
197                        component: "published snapshot",
198                    })?;
199                let snapshot = snapshot.as_ref().ok_or(StoreError::Closed)?;
200                let mut ids = Vec::new();
201                if key_range.start < key_range.end {
202                    for segment in snapshot.segments() {
203                        if matches!(segment.meta().clustering_key_range,
204                            ClusteringKeyRange::Bounded { min_ts, max_ts }
205                                if min_ts >= key_range.start && max_ts < key_range.end)
206                        {
207                            for row in segment.query_alive()?.alive_bitmap().iter() {
208                                if let Some(version) = segment
209                                    .document_version(row as usize)
210                                    .map_err(StoreError::Segment)?
211                                {
212                                    ids.push(version.doc_id());
213                                }
214                            }
215                        }
216                    }
217                }
218                ids
219            };
220            if !ids.is_empty()
221                && self
222                    .documents_have_native_nodes(&ids)
223                    .map_err(|error| StoreError::DocumentMutation(Box::new(error)))?
224            {
225                self.delete_native_documents_locked(&super::DeleteBatch::new(ids), writer)
226                    .map_err(|error| StoreError::DocumentMutation(Box::new(error)))?;
227            }
228        }
229        let state = self
230            .state
231            .lock()
232            .map_err(|_| StoreError::Synchronization { component: "state" })?;
233        match *state {
234            StoreState::Open => {}
235            StoreState::Closing => return Err(StoreError::Closing),
236            StoreState::Closed => return Err(StoreError::Closed),
237        }
238        let writer_lock = self
239            .writer_lock
240            .lock()
241            .map_err(|_| StoreError::Synchronization {
242                component: "writer lock",
243            })?;
244        if writer_lock.is_none() {
245            return Err(StoreError::ReadOnly);
246        }
247        let wal = self
248            .wal_writer
249            .lock()
250            .map_err(|_| StoreError::Synchronization {
251                component: "WAL writer",
252            })?;
253        let mut publication = wal
254            .as_ref()
255            .ok_or(StoreError::ReadOnly)?
256            .manifest_publication()?;
257        let durable_end = wal.as_ref().ok_or(StoreError::ReadOnly)?.durable_end();
258        let mut active = self
259            .active
260            .lock()
261            .map_err(|_| StoreError::Synchronization {
262                component: "active segment",
263            })?;
264        let active_state = active.as_mut().ok_or(StoreError::Closed)?;
265        if key_range.start >= key_range.end {
266            return Ok(no_op_report(active_state.generation, Vec::new()));
267        }
268        let manifest_path = self.directory.join(MANIFEST_FILE);
269        let mut manifest = match vfs.open(&manifest_path) {
270            Ok(_) => {
271                load_manifest(vfs, &manifest_path, durable_end).map_err(StoreError::Manifest)?
272            }
273            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
274                return Ok(no_op_report(active_state.generation, Vec::new()));
275            }
276            Err(source) => {
277                return Err(StoreError::Io {
278                    path: manifest_path,
279                    source,
280                });
281            }
282        };
283        let selection = select_segments(manifest.segments, &key_range)?;
284        if selection.dropped.is_empty() {
285            return Ok(no_op_report(
286                active_state.generation.max(manifest.generation),
287                selection.straddlers,
288            ));
289        }
290        let generation = active_state
291            .generation
292            .max(manifest.generation)
293            .checked_add(1)
294            .ok_or(StoreError::GenerationOverflow)?;
295        manifest.generation = generation;
296        manifest.segments = selection.retained;
297        #[cfg(feature = "graph-cypher")]
298        manifest
299            .record_generation_bump(durable_end)
300            .map_err(StoreError::Manifest)?;
301        manifest.epochs = self.epoch_registry(&manifest.epochs);
302        // Construct and validate the replacement snapshot before crossing the
303        // manifest commit point. After commit, publication is an in-memory
304        // pointer swap and cannot fail on segment I/O or accounting budget.
305        let remapped =
306            PublishedSnapshot::from_manifest(vfs, &self.directory, &manifest, &self.accounting)?;
307        publication
308            .commit_manifest(vfs, &self.directory, &manifest, self.durability_policy)
309            .map_err(StoreError::Manifest)?;
310
311        // Store admission holds `state` across the manifest commit and this
312        // publication, so a query admitted after the commit can only pin the
313        // replacement snapshot that omits every dropped segment.
314        let mut published = self
315            .snapshot
316            .write()
317            .map_err(|_| StoreError::Synchronization {
318                component: "published snapshot",
319            })?;
320        let previous = published.replace(Arc::new(remapped));
321        active_state.generation = generation;
322        #[cfg(feature = "graph-cypher")]
323        {
324            drop(published);
325            self.publish_native_documents(active_state)?;
326        }
327        publication.complete();
328        #[cfg(not(feature = "graph-cypher"))]
329        drop(published);
330        drop(previous);
331
332        let mut bytes_reclaimed = selection.bytes_reclaimed;
333        let mut orphaned_segments = Vec::new();
334        for segment in &selection.dropped {
335            let path = self.directory.join(segment.id.file_name());
336            // The manifest omission is already committed, so an unlink failure
337            // leaves an unreachable orphan rather than rolling back the drop.
338            // Queries admitted before the commit own their snapshot's open
339            // segment descriptor and mmap. POSIX unlink removes only the path;
340            // the inode remains alive until the final pinned descriptor/mapping
341            // is released, so their in-flight reads remain valid.
342            if self.has_snapshot_views() || vfs.delete(&path).is_err() {
343                bytes_reclaimed = bytes_reclaimed.saturating_sub(segment.file_size);
344                orphaned_segments.push(segment.id);
345            }
346        }
347        if let SyncRequirement::Sync(kind) = self.durability_policy.directory_sync() {
348            // The manifest commit already crossed its durable directory-sync
349            // boundary. This later sync only persists orphan cleanup and must
350            // not deny the generation already published.
351            let _ = vfs.sync(&self.directory, kind);
352        }
353        let dropped_ids = selection.dropped.iter().map(|segment| segment.id).collect();
354        drop(active);
355        drop(wal);
356        drop(writer_lock);
357        drop(state);
358        Ok(DropPartitionReport {
359            generation,
360            manifest_committed: true,
361            segments_dropped: dropped_ids,
362            bytes_reclaimed,
363            orphaned_segments,
364            straddlers_skipped: selection.straddlers,
365        })
366    }
367}
368
369fn no_op_report(generation: u64, straddlers_skipped: Vec<SegmentId>) -> DropPartitionReport {
370    DropPartitionReport {
371        generation,
372        manifest_committed: false,
373        segments_dropped: Vec::new(),
374        bytes_reclaimed: 0,
375        orphaned_segments: Vec::new(),
376        straddlers_skipped,
377    }
378}
379
380struct SegmentSelection {
381    retained: Vec<SegmentMeta>,
382    dropped: Vec<SegmentMeta>,
383    straddlers: Vec<SegmentId>,
384    bytes_reclaimed: u64,
385}
386
387fn select_segments(
388    segments: Vec<SegmentMeta>,
389    requested: &Range<i64>,
390) -> Result<SegmentSelection, StoreError> {
391    let mut retained = Vec::with_capacity(segments.len());
392    let mut dropped = Vec::new();
393    let mut straddlers = Vec::new();
394    let mut bytes_reclaimed = 0_u64;
395    for segment in segments {
396        match segment.clustering_key_range {
397            // The empty set is safely contained by every non-empty requested
398            // range, so all-tombstoned segments can be reclaimed without a
399            // numeric sentinel pretending to be a timestamp.
400            ClusteringKeyRange::Empty => {
401                bytes_reclaimed = bytes_reclaimed
402                    .checked_add(segment.file_size)
403                    .ok_or(StoreError::PartitionBytesOverflow)?;
404                dropped.push(segment);
405            }
406            ClusteringKeyRange::Bounded { min_ts, max_ts }
407                if min_ts >= requested.start && max_ts < requested.end =>
408            {
409                bytes_reclaimed = bytes_reclaimed
410                    .checked_add(segment.file_size)
411                    .ok_or(StoreError::PartitionBytesOverflow)?;
412                dropped.push(segment);
413            }
414            ClusteringKeyRange::Bounded { min_ts, max_ts }
415                if max_ts >= requested.start && min_ts < requested.end =>
416            {
417                straddlers.push(segment.id);
418                retained.push(segment);
419            }
420            ClusteringKeyRange::Unstamped | ClusteringKeyRange::Bounded { .. } => {
421                retained.push(segment);
422            }
423        }
424    }
425    Ok(SegmentSelection {
426        retained,
427        dropped,
428        straddlers,
429        bytes_reclaimed,
430    })
431}