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