Skip to main content

oxide_batch/service/
operator.rs

1//! Guarded, idempotent, audited operator actions.
2//!
3//! Every mutating action carries a bounded envelope, commits its append-only
4//! audit row in the same transaction as its effect, and is replayable by its
5//! operation identifier. The service performs no internal retry loop and never
6//! guesses an ambiguous commit outcome. The request, audit record, and guard
7//! vocabulary it applies live in `oxide-batch-repository`.
8
9use std::error::Error;
10use std::fmt;
11use std::sync::Arc;
12use std::time::SystemTime;
13
14use crate::{
15    BatchStatus, Clock, ExecutionVersion, JobExecution, JobExecutionId, JobInstanceId,
16    JobRepository, LifecycleTransition, OperationId, OperatorAction, OperatorOutcomeClass,
17    OperatorRecord, OperatorRecordDraft, OperatorRejection, OperatorRequest, ReasonCode,
18    RecoveryDirective, RecoveryRequestError, RepositoryError, RepositoryUnitOfWork,
19    TelemetryEventKind, TelemetryEventSink, TelemetryRecord,
20};
21
22/// The result of one guarded operator call.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct OperatorOutcome {
25    class: OperatorOutcomeClass,
26    record: OperatorRecord,
27    execution: Option<JobExecution>,
28    changed: bool,
29}
30
31impl OperatorOutcome {
32    const fn new(
33        class: OperatorOutcomeClass,
34        record: OperatorRecord,
35        execution: Option<JobExecution>,
36        changed: bool,
37    ) -> Self {
38        Self {
39            class,
40            record,
41            execution,
42            changed,
43        }
44    }
45
46    /// Returns whether the effect was applied, replayed, or rejected.
47    #[must_use]
48    pub const fn class(&self) -> OperatorOutcomeClass {
49        self.class
50    }
51
52    /// Borrows the durable audit record of this operation identifier.
53    #[must_use]
54    pub const fn record(&self) -> &OperatorRecord {
55        &self.record
56    }
57
58    /// Borrows the resulting execution snapshot, when the call produced one.
59    ///
60    /// A replay returns the recorded outcome without re-reading the execution.
61    #[must_use]
62    pub const fn execution(&self) -> Option<&JobExecution> {
63        self.execution.as_ref()
64    }
65
66    /// Returns the rejection class, when a guard rejected the action.
67    #[must_use]
68    pub const fn rejection(&self) -> Option<OperatorRejection> {
69        self.record.rejection()
70    }
71
72    /// Returns whether this call changed durable state.
73    ///
74    /// A repeated stop or abandon succeeds and changes nothing.
75    #[must_use]
76    pub const fn changed_state(&self) -> bool {
77        self.changed
78    }
79}
80
81/// A typed operator-service failure that is not a guard rejection.
82#[derive(Clone, Debug, Eq, PartialEq)]
83#[non_exhaustive]
84pub enum OperatorError {
85    /// The operation identifier was reused with a different canonical request.
86    OperationIdConflict {
87        /// Conflicting action.
88        action: OperatorAction,
89        /// Conflicting idempotency key.
90        operation_id: OperationId,
91    },
92    /// The commit may or may not have become durable.
93    ///
94    /// The caller resolves the ambiguity by replaying the same operation
95    /// identifier, which either returns the recorded outcome or re-attempts the
96    /// effect exactly once.
97    OperationOutcomeUnknown,
98    /// The recovery arguments could not produce a valid audited request.
99    InvalidRecoveryRequest(RecoveryRequestError),
100    /// The repository failed for a reason that is not a guard rejection.
101    Repository(RepositoryError),
102}
103
104impl fmt::Display for OperatorError {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::OperationIdConflict {
108                action,
109                operation_id,
110            } => write!(
111                formatter,
112                "operation identifier {operation_id} was already recorded for {action} with a different request"
113            ),
114            Self::OperationOutcomeUnknown => {
115                formatter.write_str("the operator commit outcome is unknown")
116            }
117            Self::InvalidRecoveryRequest(error) => error.fmt(formatter),
118            Self::Repository(error) => error.fmt(formatter),
119        }
120    }
121}
122
123impl Error for OperatorError {
124    fn source(&self) -> Option<&(dyn Error + 'static)> {
125        match self {
126            Self::InvalidRecoveryRequest(error) => Some(error),
127            Self::Repository(error) => Some(error),
128            _ => None,
129        }
130    }
131}
132
133impl From<RepositoryError> for OperatorError {
134    fn from(value: RepositoryError) -> Self {
135        match value {
136            RepositoryError::CommitOutcomeUnknown => Self::OperationOutcomeUnknown,
137            other => Self::Repository(other),
138        }
139    }
140}
141
142/// The portable guarded operator application service.
143///
144/// The service enforces lifecycle, version, definition, checkpoint,
145/// idempotency, and bounds. A deployment authenticates the caller and
146/// authorizes [`OperatorRequest::authorization_class`] before invoking it.
147/// Removing deployment authorization does not weaken a core guard.
148#[derive(Clone)]
149pub struct JobOperator<R> {
150    repository: R,
151    clock: Arc<dyn Clock>,
152    event_sinks: Vec<Arc<dyn TelemetryEventSink>>,
153}
154
155impl<R> fmt::Debug for JobOperator<R> {
156    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
157        formatter
158            .debug_struct("JobOperator")
159            .finish_non_exhaustive()
160    }
161}
162
163impl<R: JobRepository> JobOperator<R> {
164    /// Binds one repository and one injected facade clock.
165    pub const fn new(repository: R, clock: Arc<dyn Clock>) -> Self {
166        Self {
167            repository,
168            clock,
169            event_sinks: Vec::new(),
170        }
171    }
172
173    /// Attaches a non-authoritative, panic-isolated telemetry sink.
174    #[must_use]
175    pub fn with_event_sink(mut self, sink: Arc<dyn TelemetryEventSink>) -> Self {
176        self.event_sinks.push(sink);
177        self
178    }
179
180    /// Borrows the underlying repository.
181    pub const fn repository(&self) -> &R {
182        &self.repository
183    }
184
185    /// Applies one guarded, audited, idempotent operator action.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`OperatorError::OperationIdConflict`] for a reused identifier
190    /// with a different canonical request,
191    /// [`OperatorError::OperationOutcomeUnknown`] for an ambiguous commit, and
192    /// [`OperatorError::Repository`] for an infrastructure failure. A guard
193    /// rejection is an audited [`OperatorOutcomeClass::Rejected`] outcome
194    /// rather than an error.
195    pub async fn execute(
196        &self,
197        request: &OperatorRequest,
198    ) -> Result<OperatorOutcome, OperatorError> {
199        if let Some(recorded) = self.replay(request).await? {
200            self.emit_outcome(request, &recorded);
201            return Ok(recorded);
202        }
203        let requested_at = self.clock.now();
204        let mut unit = self.repository.begin().await?;
205        let effect = match self.apply(unit.as_mut(), request).await {
206            Ok(effect) => effect,
207            Err(EffectFailure::Rejected(rejection)) => {
208                // A rejection must still be audited, so the rollback is
209                // best-effort: an adapter that cannot roll back discards its
210                // connection, and a genuine outage resurfaces when the audit
211                // opens its own unit of work.
212                let _ = unit.rollback().await;
213                let outcome = self
214                    .audit_rejection(request, rejection, requested_at)
215                    .await?;
216                self.emit_outcome(request, &outcome);
217                return Ok(outcome);
218            }
219            Err(EffectFailure::Failed(error)) => {
220                // The applied effect is already lost; the failure that caused
221                // it is the informative error, not a secondary rollback fault.
222                let _ = unit.rollback().await;
223                return Err(error);
224            }
225        };
226        let draft = OperatorRecordDraft::applied(
227            request,
228            effect.job_instance_id,
229            effect.job_execution_id,
230            effect.prior_status,
231            effect.result_status,
232            requested_at,
233        );
234        let record = match unit.append_operator_request(&draft).await {
235            Ok(record) => record,
236            Err(RepositoryError::ConcurrentModification) => {
237                // A concurrent caller may have durably recorded this operation
238                // identifier between the replay probe and this append. That
239                // transaction owns the effect; this one contributed nothing, so
240                // a legitimate duplicate returns the recorded outcome rather
241                // than an error that contradicts replay by operation
242                // identifier. A conflict that is not this identifier finds no
243                // record and keeps the original error.
244                let _ = unit.rollback().await;
245                return self.replay(request).await?.ok_or(OperatorError::Repository(
246                    RepositoryError::ConcurrentModification,
247                ));
248            }
249            Err(error) => return Err(error.into()),
250        };
251        unit.commit().await?;
252        let outcome = OperatorOutcome::new(
253            OperatorOutcomeClass::Applied,
254            record,
255            effect.execution,
256            effect.changed,
257        );
258        self.emit_outcome(request, &outcome);
259        Ok(outcome)
260    }
261
262    fn emit_outcome(&self, request: &OperatorRequest, outcome: &OperatorOutcome) {
263        let primary = match outcome.class() {
264            OperatorOutcomeClass::Applied | OperatorOutcomeClass::Replayed => {
265                TelemetryEventKind::OperatorRequestAccepted
266            }
267            // The `Rejected` arm, and any outcome class added later. Telemetry
268            // must never report an outcome this build does not recognize as
269            // accepted, so it reports the non-accepting kind; the record still
270            // carries the exact class.
271            _ => TelemetryEventKind::OperatorRequestRejected,
272        };
273        self.emit_record(&TelemetryRecord::operator(
274            primary,
275            request,
276            Some(outcome.class()),
277            outcome.rejection(),
278        ));
279        if request.action() == OperatorAction::Recover {
280            let recovery = if outcome.class() == OperatorOutcomeClass::Rejected {
281                TelemetryEventKind::RecoveryRejected
282            } else {
283                TelemetryEventKind::RecoveryApplied
284            };
285            self.emit_record(&TelemetryRecord::operator(
286                recovery,
287                request,
288                Some(outcome.class()),
289                outcome.rejection(),
290            ));
291        }
292        self.emit_record(&TelemetryRecord::operator(
293            TelemetryEventKind::OperatorRequestCompleted,
294            request,
295            Some(outcome.class()),
296            outcome.rejection(),
297        ));
298    }
299
300    fn emit_record(&self, record: &TelemetryRecord) {
301        for sink in &self.event_sinks {
302            crate::telemetry::emit_safely(Some(sink), record);
303        }
304    }
305
306    async fn replay(
307        &self,
308        request: &OperatorRequest,
309    ) -> Result<Option<OperatorOutcome>, OperatorError> {
310        let mut unit = self.repository.begin().await?;
311        let recorded = unit
312            .find_operator_request(request.action(), request.operation_id())
313            .await?;
314        unit.rollback().await?;
315        let Some(record) = recorded else {
316            return Ok(None);
317        };
318        if record.digest() != request.digest() {
319            return Err(OperatorError::OperationIdConflict {
320                action: request.action(),
321                operation_id: request.operation_id().clone(),
322            });
323        }
324        Ok(Some(OperatorOutcome::new(
325            OperatorOutcomeClass::Replayed,
326            record,
327            None,
328            false,
329        )))
330    }
331
332    async fn audit_rejection(
333        &self,
334        request: &OperatorRequest,
335        rejection: OperatorRejection,
336        requested_at: SystemTime,
337    ) -> Result<OperatorOutcome, OperatorError> {
338        let draft = OperatorRecordDraft::rejected(request, rejection, requested_at);
339        let mut unit = self.repository.begin().await?;
340        let record = match unit.append_operator_request(&draft).await {
341            Ok(record) => record,
342            Err(RepositoryError::ConcurrentModification) => {
343                // As in `execute`, a concurrent caller may have recorded this
344                // operation identifier first. The rejection is already audited
345                // by that transaction.
346                let _ = unit.rollback().await;
347                return self.replay(request).await?.ok_or(OperatorError::Repository(
348                    RepositoryError::ConcurrentModification,
349                ));
350            }
351            Err(error) => return Err(error.into()),
352        };
353        unit.commit().await?;
354        Ok(OperatorOutcome::new(
355            OperatorOutcomeClass::Rejected,
356            record,
357            None,
358            false,
359        ))
360    }
361
362    async fn apply(
363        &self,
364        unit: &mut dyn RepositoryUnitOfWork,
365        request: &OperatorRequest,
366    ) -> Result<AppliedEffect, EffectFailure> {
367        match request.action() {
368            OperatorAction::Launch => self.launch(unit, request).await,
369            OperatorAction::Restart => self.restart(unit, request).await,
370            OperatorAction::Stop => self.stop(unit, request).await,
371            OperatorAction::Abandon => self.abandon(unit, request).await,
372            OperatorAction::Recover => self.recover(unit, request).await,
373            // Absorbs any action added later: the build cannot apply it, so it
374            // is an audited rejection rather than a silent success.
375            _ => Err(EffectFailure::Rejected(
376                OperatorRejection::UnsupportedAction,
377            )),
378        }
379    }
380
381    async fn launch(
382        &self,
383        unit: &mut dyn RepositoryUnitOfWork,
384        request: &OperatorRequest,
385    ) -> Result<AppliedEffect, EffectFailure> {
386        let Some(key) = request.job_instance_key() else {
387            return Err(EffectFailure::Rejected(OperatorRejection::InstanceNotFound));
388        };
389        let definition = request
390            .definition()
391            .ok_or(EffectFailure::Rejected(
392                OperatorRejection::IncompatibleDefinition,
393            ))?
394            .clone();
395        let selection = unit
396            .select_or_create_job_instance(key)
397            .await
398            .map_err(EffectFailure::classify)?;
399        let instance_id = selection.instance().id();
400        let execution = unit
401            .create_job_execution_with_definition(instance_id, &definition)
402            .await
403            .map_err(EffectFailure::classify)?;
404        Ok(AppliedEffect::created(instance_id, execution))
405    }
406
407    async fn restart(
408        &self,
409        unit: &mut dyn RepositoryUnitOfWork,
410        request: &OperatorRequest,
411    ) -> Result<AppliedEffect, EffectFailure> {
412        let Some(instance_id) = request.job_instance_id() else {
413            return Err(EffectFailure::Rejected(OperatorRejection::InstanceNotFound));
414        };
415        let definition = request
416            .definition()
417            .ok_or(EffectFailure::Rejected(
418                OperatorRejection::IncompatibleDefinition,
419            ))?
420            .clone();
421        let prior = unit
422            .job_executions(instance_id)
423            .await
424            .map_err(EffectFailure::classify)?;
425        let latest = prior.last().ok_or(EffectFailure::Rejected(
426            OperatorRejection::RestartWithoutPriorAttempt,
427        ))?;
428        let prior_status = latest.metadata().status();
429        if matches!(prior_status, BatchStatus::Unknown) {
430            return Err(EffectFailure::Rejected(OperatorRejection::InvalidState {
431                status: prior_status,
432            }));
433        }
434        let execution = unit
435            .create_job_execution_with_definition(instance_id, &definition)
436            .await
437            .map_err(EffectFailure::classify)?;
438        Ok(AppliedEffect::created(instance_id, execution).with_prior(prior_status))
439    }
440
441    async fn stop(
442        &self,
443        unit: &mut dyn RepositoryUnitOfWork,
444        request: &OperatorRequest,
445    ) -> Result<AppliedEffect, EffectFailure> {
446        let (id, expected_version) = execution_target(request)?;
447        let observed = unit
448            .get_job_execution(id)
449            .await
450            .map_err(EffectFailure::classify)?
451            .ok_or(EffectFailure::Rejected(
452                OperatorRejection::ExecutionNotFound,
453            ))?;
454        let status = observed.metadata().status();
455        if !matches!(status, BatchStatus::Starting | BatchStatus::Started) {
456            if matches!(status, BatchStatus::Stopping) || status.is_finished() {
457                // A repeat request on a stopping or terminal execution succeeds
458                // and changes nothing.
459                return Ok(AppliedEffect::unchanged(&observed));
460            }
461            return Err(EffectFailure::Rejected(OperatorRejection::InvalidState {
462                status,
463            }));
464        }
465        let execution = unit
466            .request_execution_stop(id, expected_version, request.actor(), self.clock.now())
467            .await
468            .map_err(EffectFailure::classify)?;
469        Ok(AppliedEffect::updated(&execution, status))
470    }
471
472    async fn abandon(
473        &self,
474        unit: &mut dyn RepositoryUnitOfWork,
475        request: &OperatorRequest,
476    ) -> Result<AppliedEffect, EffectFailure> {
477        let (id, expected_version) = execution_target(request)?;
478        let observed = unit
479            .get_job_execution(id)
480            .await
481            .map_err(EffectFailure::classify)?
482            .ok_or(EffectFailure::Rejected(
483                OperatorRejection::ExecutionNotFound,
484            ))?;
485        let status = observed.metadata().status();
486        match status {
487            BatchStatus::Abandoned => return Ok(AppliedEffect::unchanged(&observed)),
488            BatchStatus::Stopped | BatchStatus::Failed => {}
489            BatchStatus::Unknown => {
490                let decision = unit
491                    .recovery_decision(id)
492                    .await
493                    .map_err(EffectFailure::classify)?;
494                if decision.is_none() {
495                    return Err(EffectFailure::Rejected(
496                        OperatorRejection::UnresolvedRecoveryRequired,
497                    ));
498                }
499            }
500            other => {
501                return Err(EffectFailure::Rejected(OperatorRejection::InvalidState {
502                    status: other,
503                }));
504            }
505        }
506        let transition = LifecycleTransition::new(BatchStatus::Abandoned, self.clock.now());
507        let execution = unit
508            .transition_job_execution(id, expected_version, transition)
509            .await
510            .map_err(EffectFailure::classify)?;
511        Ok(AppliedEffect::updated(&execution, status))
512    }
513
514    async fn recover(
515        &self,
516        unit: &mut dyn RepositoryUnitOfWork,
517        request: &OperatorRequest,
518    ) -> Result<AppliedEffect, EffectFailure> {
519        let (id, expected_version) = execution_target(request)?;
520        let execution = unit
521            .get_job_execution(id)
522            .await
523            .map_err(EffectFailure::classify)?
524            .ok_or(EffectFailure::Rejected(
525                OperatorRejection::ExecutionNotFound,
526            ))?;
527        if execution.version() != expected_version {
528            return Err(EffectFailure::Rejected(
529                OperatorRejection::OptimisticConflict {
530                    current: execution.version(),
531                },
532            ));
533        }
534        let (directive, unknown_commit) = request.recovery_guard().ok_or(
535            EffectFailure::Rejected(OperatorRejection::InvalidState {
536                status: execution.metadata().status(),
537            }),
538        )?;
539        if unknown_commit
540            && matches!(directive, RecoveryDirective::MarkFailed(_))
541            && request.reason().map(ReasonCode::as_str) != Some("UNKNOWN_EFFECT")
542        {
543            return Err(EffectFailure::Rejected(
544                OperatorRejection::UnresolvedRecoveryRequired,
545            ));
546        }
547        let recovery = request
548            .recovery_request()
549            .ok_or(EffectFailure::Rejected(OperatorRejection::InvalidState {
550                status: BatchStatus::Unknown,
551            }))?
552            .map_err(|error| EffectFailure::Failed(OperatorError::InvalidRecoveryRequest(error)))?;
553        let result = unit
554            .recover_job_execution(id, &recovery)
555            .await
556            .map_err(EffectFailure::classify)?;
557        Ok(AppliedEffect::updated(
558            result.execution(),
559            result.decision().prior_status(),
560        ))
561    }
562}
563
564fn execution_target(
565    request: &OperatorRequest,
566) -> Result<(JobExecutionId, ExecutionVersion), EffectFailure> {
567    let Some(id) = request.job_execution_id() else {
568        return Err(EffectFailure::Rejected(
569            OperatorRejection::ExecutionNotFound,
570        ));
571    };
572    let expected_version = request.expected_version().ok_or(EffectFailure::Rejected(
573        OperatorRejection::InvalidState {
574            status: BatchStatus::Unknown,
575        },
576    ))?;
577    Ok((id, expected_version))
578}
579
580struct AppliedEffect {
581    job_instance_id: Option<JobInstanceId>,
582    job_execution_id: Option<JobExecutionId>,
583    prior_status: Option<BatchStatus>,
584    result_status: Option<BatchStatus>,
585    execution: Option<JobExecution>,
586    changed: bool,
587}
588
589impl AppliedEffect {
590    fn created(instance_id: JobInstanceId, execution: JobExecution) -> Self {
591        Self {
592            job_instance_id: Some(instance_id),
593            job_execution_id: Some(execution.id()),
594            prior_status: None,
595            result_status: Some(execution.metadata().status()),
596            execution: Some(execution),
597            changed: true,
598        }
599    }
600
601    fn updated(execution: &JobExecution, prior_status: BatchStatus) -> Self {
602        Self {
603            job_instance_id: Some(execution.job_instance_id()),
604            job_execution_id: Some(execution.id()),
605            prior_status: Some(prior_status),
606            result_status: Some(execution.metadata().status()),
607            execution: Some(execution.clone()),
608            changed: true,
609        }
610    }
611
612    fn unchanged(execution: &JobExecution) -> Self {
613        let status = execution.metadata().status();
614        Self {
615            job_instance_id: Some(execution.job_instance_id()),
616            job_execution_id: Some(execution.id()),
617            prior_status: Some(status),
618            result_status: Some(status),
619            execution: Some(execution.clone()),
620            changed: false,
621        }
622    }
623
624    const fn with_prior(mut self, prior_status: BatchStatus) -> Self {
625        self.prior_status = Some(prior_status);
626        self
627    }
628}
629
630enum EffectFailure {
631    Rejected(OperatorRejection),
632    Failed(OperatorError),
633}
634
635impl EffectFailure {
636    fn classify(error: RepositoryError) -> Self {
637        OperatorRejection::from_repository(&error)
638            .map_or_else(|| Self::Failed(OperatorError::from(error)), Self::Rejected)
639    }
640}