Skip to main content

oxide_batch/service/
retention.rs

1//! The portable, audited retention service.
2//!
3//! Purge is planned and applied in two guarded phases. The durable holds, purge
4//! plans, and audit records the service exchanges with a repository live in
5//! `oxide-batch-repository`.
6
7use std::fmt;
8use std::sync::Arc;
9
10use crate::{
11    ActorRef, Clock, JobInstanceId, JobRepository, OperationId, PurgeCounts, PurgePlan,
12    PurgePlanRequest, ReasonCode, RetentionAction, RetentionError, RetentionHold, RetentionOutcome,
13    RetentionRecord, RetentionRecordDraft, TelemetryEventKind, TelemetryEventSink, TelemetryRecord,
14};
15
16/// The result of one audited retention call.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct RetentionReport {
19    outcome: RetentionOutcome,
20    record: RetentionRecord,
21    hold: Option<RetentionHold>,
22}
23
24impl RetentionReport {
25    const fn new(
26        outcome: RetentionOutcome,
27        record: RetentionRecord,
28        hold: Option<RetentionHold>,
29    ) -> Self {
30        Self {
31            outcome,
32            record,
33            hold,
34        }
35    }
36
37    /// Returns whether the action was applied, replayed, or rejected.
38    #[must_use]
39    pub const fn outcome(&self) -> RetentionOutcome {
40        self.outcome
41    }
42
43    /// Borrows the durable audit record.
44    #[must_use]
45    pub const fn record(&self) -> &RetentionRecord {
46        &self.record
47    }
48
49    /// Borrows the hold this call placed, when it placed one.
50    #[must_use]
51    pub const fn hold(&self) -> Option<&RetentionHold> {
52        self.hold.as_ref()
53    }
54
55    /// Returns the per-table deleted counts.
56    #[must_use]
57    pub const fn counts(&self) -> PurgeCounts {
58        self.record.counts()
59    }
60}
61
62/// The portable retention service.
63///
64/// Purge requires the operator-writer role and its narrowly granted deletes.
65/// The runtime role cannot purge, and the operator-reader role can plan but
66/// not apply. Those privileges are enforced by the durable adapter's
67/// deployment configuration, not by this type.
68#[derive(Clone)]
69pub struct RetentionService<R> {
70    repository: R,
71    clock: Arc<dyn Clock>,
72    event_sinks: Vec<Arc<dyn TelemetryEventSink>>,
73}
74
75impl<R> fmt::Debug for RetentionService<R> {
76    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77        formatter
78            .debug_struct("RetentionService")
79            .finish_non_exhaustive()
80    }
81}
82
83impl<R: JobRepository> RetentionService<R> {
84    /// Binds one repository and one injected facade clock.
85    pub const fn new(repository: R, clock: Arc<dyn Clock>) -> Self {
86        Self {
87            repository,
88            clock,
89            event_sinks: Vec::new(),
90        }
91    }
92
93    /// Attaches a non-authoritative, panic-isolated telemetry sink.
94    #[must_use]
95    pub fn with_event_sink(mut self, sink: Arc<dyn TelemetryEventSink>) -> Self {
96        self.event_sinks.push(sink);
97        self
98    }
99
100    /// Borrows the underlying repository.
101    pub const fn repository(&self) -> &R {
102        &self.repository
103    }
104
105    /// Reads the active hold of one logical instance.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`RetentionError::Repository`] when the read fails.
110    pub async fn hold(
111        &self,
112        job_instance_id: JobInstanceId,
113    ) -> Result<Option<RetentionHold>, RetentionError> {
114        let mut unit = self.repository.begin().await?;
115        let hold = unit.job_instance_hold(job_instance_id).await?;
116        unit.rollback().await?;
117        Ok(hold)
118    }
119
120    /// Places one audited hold on a logical instance.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`RetentionError::OperationIdConflict`] for a reused identifier
125    /// and [`RetentionError::Repository`] for an infrastructure failure.
126    pub async fn place_hold(
127        &self,
128        operation_id: OperationId,
129        actor: ActorRef,
130        reason: ReasonCode,
131        job_instance_id: JobInstanceId,
132    ) -> Result<RetentionReport, RetentionError> {
133        let result = self
134            .hold_action(
135                RetentionAction::Hold,
136                operation_id,
137                actor,
138                reason,
139                job_instance_id,
140            )
141            .await;
142        if let Ok(report) = &result {
143            self.emit_report(report);
144        }
145        result
146    }
147
148    /// Releases the hold on a logical instance.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`RetentionError::OperationIdConflict`] for a reused identifier
153    /// and [`RetentionError::Repository`] for an infrastructure failure.
154    pub async fn release_hold(
155        &self,
156        operation_id: OperationId,
157        actor: ActorRef,
158        reason: ReasonCode,
159        job_instance_id: JobInstanceId,
160    ) -> Result<RetentionReport, RetentionError> {
161        let result = self
162            .hold_action(
163                RetentionAction::ReleaseHold,
164                operation_id,
165                actor,
166                reason,
167                job_instance_id,
168            )
169            .await;
170        if let Ok(report) = &result {
171            self.emit_report(report);
172        }
173        result
174    }
175
176    async fn hold_action(
177        &self,
178        action: RetentionAction,
179        operation_id: OperationId,
180        actor: ActorRef,
181        reason: ReasonCode,
182        job_instance_id: JobInstanceId,
183    ) -> Result<RetentionReport, RetentionError> {
184        if let Some(record) = self.replay(action, &operation_id).await? {
185            return Ok(RetentionReport::new(
186                RetentionOutcome::Replayed,
187                record,
188                None,
189            ));
190        }
191        let applied_at = self.clock.now();
192        let mut unit = self.repository.begin().await?;
193        let hold = match action {
194            RetentionAction::Hold => Some(
195                unit.place_instance_hold(job_instance_id, &actor, &reason, applied_at)
196                    .await?,
197            ),
198            RetentionAction::ReleaseHold => {
199                unit.release_instance_hold(job_instance_id).await?;
200                None
201            }
202            // Absorbs `ApplyPurge` and any action added later. The private
203            // caller passes only `Hold` and `ReleaseHold`, and neither absorbed
204            // action changes hold state.
205            _ => None,
206        };
207        let draft = RetentionRecordDraft::instance_action(
208            action,
209            operation_id,
210            actor,
211            reason,
212            job_instance_id,
213            applied_at,
214        );
215        let record = unit.append_retention_action(&draft).await?;
216        unit.commit().await?;
217        Ok(RetentionReport::new(
218            RetentionOutcome::Applied,
219            record,
220            hold,
221        ))
222    }
223
224    /// Produces one bounded, digest-guarded purge plan.
225    ///
226    /// Planning is a read-only action of the [`AuthorizationClass::Read`]
227    /// class. It deletes nothing.
228    ///
229    /// [`AuthorizationClass::Read`]: crate::AuthorizationClass::Read
230    ///
231    /// # Errors
232    ///
233    /// Returns [`RetentionError::Repository`] when the survey fails.
234    pub async fn plan_purge(
235        &self,
236        request: &PurgePlanRequest,
237    ) -> Result<PurgePlan, RetentionError> {
238        let mut unit = self.repository.begin().await?;
239        let survey = unit.purge_survey(request).await?;
240        unit.rollback().await?;
241        let plan = PurgePlan::new(request.clone(), survey);
242        self.emit_record(&TelemetryRecord::retention(
243            TelemetryEventKind::RetentionPlanned,
244            None,
245            None,
246            PurgeCounts::default(),
247        ));
248        Ok(plan)
249    }
250
251    /// Applies one bounded purge batch under its plan digest.
252    ///
253    /// Application re-validates eligibility and observed versions inside one
254    /// transaction. Any candidate that changed produces
255    /// [`RetentionError::RetentionPlanStale`] and deletes nothing.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`RetentionError::RetentionPlanStale`] for a changed candidate,
260    /// [`RetentionError::OperationIdConflict`] for a reused identifier,
261    /// [`RetentionError::OperationOutcomeUnknown`] for an ambiguous commit, and
262    /// [`RetentionError::Repository`] for an infrastructure failure.
263    pub async fn apply_purge(
264        &self,
265        operation_id: OperationId,
266        actor: ActorRef,
267        reason: ReasonCode,
268        plan: &PurgePlan,
269    ) -> Result<RetentionReport, RetentionError> {
270        if let Some(record) = self
271            .replay(RetentionAction::ApplyPurge, &operation_id)
272            .await?
273        {
274            let report = RetentionReport::new(RetentionOutcome::Replayed, record, None);
275            self.emit_report(&report);
276            return Ok(report);
277        }
278        let applied_at = self.clock.now();
279        let mut unit = self.repository.begin().await?;
280        let counts = unit.apply_purge(plan).await?;
281        let draft = RetentionRecordDraft::purge(
282            operation_id,
283            actor,
284            reason,
285            *plan.digest(),
286            counts,
287            plan.request().batch(),
288            applied_at,
289        );
290        let record = unit.append_retention_action(&draft).await?;
291        unit.commit().await?;
292        let report = RetentionReport::new(RetentionOutcome::Applied, record, None);
293        self.emit_report(&report);
294        Ok(report)
295    }
296
297    async fn replay(
298        &self,
299        action: RetentionAction,
300        operation_id: &OperationId,
301    ) -> Result<Option<RetentionRecord>, RetentionError> {
302        let mut unit = self.repository.begin().await?;
303        let recorded = unit.find_retention_action(action, operation_id).await?;
304        unit.rollback().await?;
305        Ok(recorded)
306    }
307
308    fn emit_report(&self, report: &RetentionReport) {
309        self.emit_record(&TelemetryRecord::retention(
310            TelemetryEventKind::RetentionApplied,
311            Some(report.record().action()),
312            Some(report.outcome()),
313            report.counts(),
314        ));
315    }
316
317    fn emit_record(&self, record: &TelemetryRecord) {
318        for sink in &self.event_sinks {
319            crate::telemetry::emit_safely(Some(sink), record);
320        }
321    }
322}