Skip to main content

oxide_batch/service/
explorer.rs

1//! The bounded, redacted metadata inspection service.
2//!
3//! The service owns page bounds, cursor identity, traversal ceilings, and the
4//! encoded response bound. The adapter owns one statement per page. The
5//! projections, queries, cursors, and the port itself live in
6//! `oxide-batch-repository`.
7
8use std::fmt;
9use std::sync::Arc;
10use std::time::Duration;
11
12use crate::{
13    ExplorerError, ExplorerQuery, ExplorerRepository, FlowDecision, JobExecutionId,
14    JobExecutionProjection, JobInstanceId, JobInstanceProjection, JobName, MIN_UNRESOLVED_AGE,
15    OperatorRecord, Page, PageRequest, QueryWindow, RecoveryDecision, StepExecutionId,
16    StepExecutionProjection, StepPartitionProjection, TelemetryEventSink, TelemetryRecord,
17};
18use oxide_batch_repository::{page, resume_window, start_window};
19
20/// The portable bounded inspection service.
21///
22/// The service owns page bounds, cursor identity, traversal ceilings, and the
23/// encoded response bound. The adapter owns one statement per page.
24#[derive(Clone)]
25pub struct JobExplorer<S> {
26    source: S,
27    event_sinks: Vec<Arc<dyn TelemetryEventSink>>,
28}
29
30impl<S: fmt::Debug> fmt::Debug for JobExplorer<S> {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        formatter
33            .debug_struct("JobExplorer")
34            .field("source", &self.source)
35            .field("event_sinks", &self.event_sinks.len())
36            .finish()
37    }
38}
39
40impl<S: ExplorerRepository> JobExplorer<S> {
41    /// Wraps one bounded read port.
42    pub const fn new(source: S) -> Self {
43        Self {
44            source,
45            event_sinks: Vec::new(),
46        }
47    }
48
49    /// Attaches a non-authoritative, panic-isolated telemetry sink.
50    #[must_use]
51    pub fn with_event_sink(mut self, sink: Arc<dyn TelemetryEventSink>) -> Self {
52        self.event_sinks.push(sink);
53        self
54    }
55
56    /// Borrows the underlying read port.
57    pub const fn source(&self) -> &S {
58        &self.source
59    }
60
61    /// Lists registered job names in byte order.
62    ///
63    /// # Errors
64    ///
65    /// Returns a typed cursor, bound, timeout, or repository failure.
66    pub async fn list_job_names(
67        &self,
68        request: &PageRequest,
69    ) -> Result<Page<JobName>, ExplorerError> {
70        let query = ExplorerQuery::JobNames;
71        let window = self.window(&query, request).await?;
72        let rows = self.source.job_names(&window).await?;
73        self.finish_page(None, page(&query, request, window.ceiling(), rows))
74    }
75
76    /// Lists instances of one job name, newest identity first.
77    ///
78    /// # Errors
79    ///
80    /// Returns a typed cursor, bound, timeout, or repository failure.
81    pub async fn list_instances(
82        &self,
83        job_name: &JobName,
84        request: &PageRequest,
85    ) -> Result<Page<JobInstanceProjection>, ExplorerError> {
86        let query = ExplorerQuery::Instances {
87            job_name: job_name.clone(),
88        };
89        let window = self.window(&query, request).await?;
90        let rows = self.source.instances(job_name, &window).await?;
91        self.finish_page(None, page(&query, request, window.ceiling(), rows))
92    }
93
94    /// Lists executions of one instance, newest attempt first.
95    ///
96    /// # Errors
97    ///
98    /// Returns a typed cursor, bound, timeout, or repository failure.
99    pub async fn list_executions(
100        &self,
101        job_instance_id: JobInstanceId,
102        request: &PageRequest,
103    ) -> Result<Page<JobExecutionProjection>, ExplorerError> {
104        let query = ExplorerQuery::Executions { job_instance_id };
105        let window = self.window(&query, request).await?;
106        let rows = self.source.executions(job_instance_id, &window).await?;
107        self.finish_page(None, page(&query, request, window.ceiling(), rows))
108    }
109
110    /// Reads one execution projection.
111    ///
112    /// # Errors
113    ///
114    /// Returns a typed timeout or repository failure.
115    pub async fn get_execution(
116        &self,
117        job_execution_id: JobExecutionId,
118    ) -> Result<Option<JobExecutionProjection>, ExplorerError> {
119        self.source.execution(job_execution_id).await
120    }
121
122    /// Lists step executions of one job execution.
123    ///
124    /// # Errors
125    ///
126    /// Returns a typed cursor, bound, timeout, or repository failure.
127    pub async fn list_step_executions(
128        &self,
129        job_execution_id: JobExecutionId,
130        request: &PageRequest,
131    ) -> Result<Page<StepExecutionProjection>, ExplorerError> {
132        let query = ExplorerQuery::StepExecutions { job_execution_id };
133        let window = self.window(&query, request).await?;
134        let rows = self
135            .source
136            .step_executions(job_execution_id, &window)
137            .await?;
138        self.finish_page(
139            Some(job_execution_id),
140            page(&query, request, window.ceiling(), rows),
141        )
142    }
143
144    /// Lists non-terminal executions older than an explicit age bound.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`ExplorerError::AgeBoundTooSmall`] below [`MIN_UNRESOLVED_AGE`],
149    /// or a typed cursor, bound, timeout, or repository failure.
150    pub async fn list_unresolved_executions(
151        &self,
152        minimum_age: Duration,
153        request: &PageRequest,
154    ) -> Result<Page<JobExecutionProjection>, ExplorerError> {
155        if minimum_age < MIN_UNRESOLVED_AGE {
156            return Err(ExplorerError::AgeBoundTooSmall {
157                minimum: MIN_UNRESOLVED_AGE,
158            });
159        }
160        let query = ExplorerQuery::UnresolvedExecutions { minimum_age };
161        let window = self.window(&query, request).await?;
162        let rows = self
163            .source
164            .unresolved_executions(minimum_age, &window)
165            .await?;
166        self.finish_page(None, page(&query, request, window.ceiling(), rows))
167    }
168
169    /// Lists recovery decisions of one job execution.
170    ///
171    /// # Errors
172    ///
173    /// Returns a typed cursor, bound, timeout, or repository failure.
174    pub async fn list_recovery_decisions(
175        &self,
176        job_execution_id: JobExecutionId,
177        request: &PageRequest,
178    ) -> Result<Page<RecoveryDecision>, ExplorerError> {
179        let query = ExplorerQuery::RecoveryDecisions { job_execution_id };
180        let window = self.window(&query, request).await?;
181        let rows = self
182            .source
183            .recovery_decisions(job_execution_id, &window)
184            .await?;
185        self.finish_page(
186            Some(job_execution_id),
187            page(&query, request, window.ceiling(), rows),
188        )
189    }
190
191    /// Lists flow decisions of one job execution in sequence order.
192    ///
193    /// # Errors
194    ///
195    /// Returns a typed cursor, bound, timeout, or repository failure.
196    pub async fn list_flow_decisions(
197        &self,
198        job_execution_id: JobExecutionId,
199        request: &PageRequest,
200    ) -> Result<Page<FlowDecision>, ExplorerError> {
201        let query = ExplorerQuery::FlowDecisions { job_execution_id };
202        let window = self.window(&query, request).await?;
203        let rows = self
204            .source
205            .flow_decisions(job_execution_id, &window)
206            .await?;
207        self.finish_page(
208            Some(job_execution_id),
209            page(&query, request, window.ceiling(), rows),
210        )
211    }
212
213    /// Lists partitions of one partitioned step execution.
214    ///
215    /// # Errors
216    ///
217    /// Returns a typed cursor, bound, timeout, or repository failure.
218    pub async fn list_step_partitions(
219        &self,
220        step_execution_id: StepExecutionId,
221        request: &PageRequest,
222    ) -> Result<Page<StepPartitionProjection>, ExplorerError> {
223        let query = ExplorerQuery::StepPartitions { step_execution_id };
224        let window = self.window(&query, request).await?;
225        let rows = self
226            .source
227            .step_partitions(step_execution_id, &window)
228            .await?;
229        self.finish_page(None, page(&query, request, window.ceiling(), rows))
230    }
231
232    /// Lists audited operator requests for one job execution.
233    ///
234    /// # Errors
235    ///
236    /// Returns a typed cursor, bound, timeout, or repository failure.
237    pub async fn list_operator_requests(
238        &self,
239        job_execution_id: JobExecutionId,
240        request: &PageRequest,
241    ) -> Result<Page<OperatorRecord>, ExplorerError> {
242        let query = ExplorerQuery::OperatorRequests { job_execution_id };
243        let window = self.window(&query, request).await?;
244        let rows = self
245            .source
246            .operator_requests(job_execution_id, &window)
247            .await?;
248        self.finish_page(
249            Some(job_execution_id),
250            page(&query, request, window.ceiling(), rows),
251        )
252    }
253
254    fn finish_page<T>(
255        &self,
256        execution_id: Option<JobExecutionId>,
257        result: Result<Page<T>, ExplorerError>,
258    ) -> Result<Page<T>, ExplorerError> {
259        if result.is_ok() {
260            let record = TelemetryRecord::explorer(execution_id);
261            for sink in &self.event_sinks {
262                crate::telemetry::emit_safely(Some(sink), &record);
263            }
264        }
265        result
266    }
267
268    async fn window(
269        &self,
270        query: &ExplorerQuery,
271        request: &PageRequest,
272    ) -> Result<QueryWindow, ExplorerError> {
273        match request.cursor() {
274            None => {
275                let ceiling = self.source.identity_ceiling(query).await?;
276                Ok(start_window(request, ceiling))
277            }
278            Some(cursor) => resume_window(cursor, query, request),
279        }
280    }
281}