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, commit_manifest, 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        let _data_read_audit =
164            crate::segment::reader::install_data_read_audit(vfs.segment_data_read_counter());
165        let _maintenance = self
166            .maintenance
167            .lock()
168            .map_err(|_| StoreError::Synchronization {
169                component: "partition retention",
170            })?;
171        let state = self
172            .state
173            .lock()
174            .map_err(|_| StoreError::Synchronization { component: "state" })?;
175        match *state {
176            StoreState::Open => {}
177            StoreState::Closing => return Err(StoreError::Closing),
178            StoreState::Closed => return Err(StoreError::Closed),
179        }
180        let writer_lock = self
181            .writer_lock
182            .lock()
183            .map_err(|_| StoreError::Synchronization {
184                component: "writer lock",
185            })?;
186        if writer_lock.is_none() {
187            return Err(StoreError::ReadOnly);
188        }
189        let wal = self
190            .wal_writer
191            .lock()
192            .map_err(|_| StoreError::Synchronization {
193                component: "WAL writer",
194            })?;
195        let durable_end = wal.as_ref().ok_or(StoreError::ReadOnly)?.durable_end();
196        let mut active = self
197            .active
198            .lock()
199            .map_err(|_| StoreError::Synchronization {
200                component: "active segment",
201            })?;
202        let active_state = active.as_mut().ok_or(StoreError::Closed)?;
203        if key_range.start >= key_range.end {
204            return Ok(no_op_report(active_state.generation, Vec::new()));
205        }
206        let manifest_path = self.directory.join(MANIFEST_FILE);
207        let mut manifest = match vfs.open(&manifest_path) {
208            Ok(_) => {
209                load_manifest(vfs, &manifest_path, durable_end).map_err(StoreError::Manifest)?
210            }
211            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
212                return Ok(no_op_report(active_state.generation, Vec::new()));
213            }
214            Err(source) => {
215                return Err(StoreError::Io {
216                    path: manifest_path,
217                    source,
218                });
219            }
220        };
221        let selection = select_segments(manifest.segments, &key_range)?;
222        if selection.dropped.is_empty() {
223            return Ok(no_op_report(
224                active_state.generation.max(manifest.generation),
225                selection.straddlers,
226            ));
227        }
228        let generation = active_state
229            .generation
230            .max(manifest.generation)
231            .checked_add(1)
232            .ok_or(StoreError::GenerationOverflow)?;
233        manifest.generation = generation;
234        manifest.segments = selection.retained;
235        manifest.epochs = self.epoch_registry(&manifest.epochs);
236        // Construct and validate the replacement snapshot before crossing the
237        // manifest commit point. After commit, publication is an in-memory
238        // pointer swap and cannot fail on segment I/O or accounting budget.
239        let remapped =
240            PublishedSnapshot::from_manifest(vfs, &self.directory, &manifest, &self.accounting)?;
241        commit_manifest(vfs, &self.directory, &manifest, self.durability_policy)
242            .map_err(StoreError::Manifest)?;
243
244        // Store admission holds `state` across the manifest commit and this
245        // publication, so a query admitted after the commit can only pin the
246        // replacement snapshot that omits every dropped segment.
247        let mut published = self
248            .snapshot
249            .write()
250            .map_err(|_| StoreError::Synchronization {
251                component: "published snapshot",
252            })?;
253        let previous = published.replace(Arc::new(remapped));
254        active_state.generation = generation;
255        drop(published);
256        drop(previous);
257
258        let mut bytes_reclaimed = selection.bytes_reclaimed;
259        let mut orphaned_segments = Vec::new();
260        for segment in &selection.dropped {
261            let path = self.directory.join(segment.id.file_name());
262            // The manifest omission is already committed, so an unlink failure
263            // leaves an unreachable orphan rather than rolling back the drop.
264            // Queries admitted before the commit own their snapshot's open
265            // segment descriptor and mmap. POSIX unlink removes only the path;
266            // the inode remains alive until the final pinned descriptor/mapping
267            // is released, so their in-flight reads remain valid.
268            if self.has_snapshot_views() || vfs.delete(&path).is_err() {
269                bytes_reclaimed = bytes_reclaimed.saturating_sub(segment.file_size);
270                orphaned_segments.push(segment.id);
271            }
272        }
273        if let SyncRequirement::Sync(kind) = self.durability_policy.directory_sync() {
274            // The manifest commit already crossed its durable directory-sync
275            // boundary. This later sync only persists orphan cleanup and must
276            // not deny the generation already published.
277            let _ = vfs.sync(&self.directory, kind);
278        }
279        let dropped_ids = selection.dropped.iter().map(|segment| segment.id).collect();
280        drop(active);
281        drop(wal);
282        drop(writer_lock);
283        drop(state);
284        Ok(DropPartitionReport {
285            generation,
286            manifest_committed: true,
287            segments_dropped: dropped_ids,
288            bytes_reclaimed,
289            orphaned_segments,
290            straddlers_skipped: selection.straddlers,
291        })
292    }
293}
294
295fn no_op_report(generation: u64, straddlers_skipped: Vec<SegmentId>) -> DropPartitionReport {
296    DropPartitionReport {
297        generation,
298        manifest_committed: false,
299        segments_dropped: Vec::new(),
300        bytes_reclaimed: 0,
301        orphaned_segments: Vec::new(),
302        straddlers_skipped,
303    }
304}
305
306struct SegmentSelection {
307    retained: Vec<SegmentMeta>,
308    dropped: Vec<SegmentMeta>,
309    straddlers: Vec<SegmentId>,
310    bytes_reclaimed: u64,
311}
312
313fn select_segments(
314    segments: Vec<SegmentMeta>,
315    requested: &Range<i64>,
316) -> Result<SegmentSelection, StoreError> {
317    let mut retained = Vec::with_capacity(segments.len());
318    let mut dropped = Vec::new();
319    let mut straddlers = Vec::new();
320    let mut bytes_reclaimed = 0_u64;
321    for segment in segments {
322        match segment.clustering_key_range {
323            // The empty set is safely contained by every non-empty requested
324            // range, so all-tombstoned segments can be reclaimed without a
325            // numeric sentinel pretending to be a timestamp.
326            ClusteringKeyRange::Empty => {
327                bytes_reclaimed = bytes_reclaimed
328                    .checked_add(segment.file_size)
329                    .ok_or(StoreError::PartitionBytesOverflow)?;
330                dropped.push(segment);
331            }
332            ClusteringKeyRange::Bounded { min_ts, max_ts }
333                if min_ts >= requested.start && max_ts < requested.end =>
334            {
335                bytes_reclaimed = bytes_reclaimed
336                    .checked_add(segment.file_size)
337                    .ok_or(StoreError::PartitionBytesOverflow)?;
338                dropped.push(segment);
339            }
340            ClusteringKeyRange::Bounded { min_ts, max_ts }
341                if max_ts >= requested.start && min_ts < requested.end =>
342            {
343                straddlers.push(segment.id);
344                retained.push(segment);
345            }
346            ClusteringKeyRange::Unstamped | ClusteringKeyRange::Bounded { .. } => {
347                retained.push(segment);
348            }
349        }
350    }
351    Ok(SegmentSelection {
352        retained,
353        dropped,
354        straddlers,
355        bytes_reclaimed,
356    })
357}