1use std::fmt;
9use std::time::SystemTime;
10
11use oxide_batch_core::{
12 BatchStatus, DefinitionIdentity, ExecutionVersion, FailureSummary, JobExecutionId,
13 JobInstanceId, JobInstanceKey, LifecycleError, OperatorRequestId,
14};
15
16use crate::{
17 ActorRef, AuthorizationClass, OperationId, OperatorAction, ReasonCode, RecoveryDisposition,
18 RecoveryProposal, RecoveryRequest, RecoveryRequestError, RepositoryError, RequestArguments,
19 RequestDigest, hex_digest, request_digest,
20};
21
22#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct OperatorRequest {
29 action: OperatorAction,
30 operation_id: OperationId,
31 actor: ActorRef,
32 reason: Option<ReasonCode>,
33 target: OperatorTarget,
34 expected_version: Option<ExecutionVersion>,
35 arguments: RequestArguments,
36 digest: RequestDigest,
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47#[non_exhaustive]
48pub enum RecoveryDirective {
49 MarkFailed(FailureSummary),
51 Abandon,
53}
54
55impl RecoveryDirective {
56 #[must_use]
58 pub const fn disposition(self) -> RecoveryDisposition {
59 match self {
60 Self::MarkFailed(_) => RecoveryDisposition::MarkFailed,
61 Self::Abandon => RecoveryDisposition::Abandon,
62 }
63 }
64
65 #[must_use]
67 pub const fn failure(self) -> Option<FailureSummary> {
68 match self {
69 Self::MarkFailed(failure) => Some(failure),
70 Self::Abandon => None,
71 }
72 }
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
76enum OperatorTarget {
77 InstanceKey(Box<JobInstanceKey>),
78 Instance(JobInstanceId),
79 Execution(JobExecutionId),
80}
81
82impl OperatorTarget {
83 fn identity(&self) -> String {
84 match self {
85 Self::InstanceKey(key) => {
86 format!("instance-key:{}", hex_digest(&key.digest()))
87 }
88 Self::Instance(id) => format!("instance:{id}"),
89 Self::Execution(id) => format!("execution:{id}"),
90 }
91 }
92}
93
94impl OperatorRequest {
95 #[must_use]
97 pub fn launch(
98 operation_id: OperationId,
99 actor: ActorRef,
100 key: JobInstanceKey,
101 definition: DefinitionIdentity,
102 ) -> Self {
103 Self::build(
104 OperatorAction::Launch,
105 operation_id,
106 actor,
107 None,
108 OperatorTarget::InstanceKey(Box::new(key)),
109 None,
110 RequestArguments::Definition(Box::new(definition)),
111 )
112 }
113
114 #[must_use]
116 pub fn restart(
117 operation_id: OperationId,
118 actor: ActorRef,
119 job_instance_id: JobInstanceId,
120 definition: DefinitionIdentity,
121 ) -> Self {
122 Self::build(
123 OperatorAction::Restart,
124 operation_id,
125 actor,
126 None,
127 OperatorTarget::Instance(job_instance_id),
128 None,
129 RequestArguments::Definition(Box::new(definition)),
130 )
131 }
132
133 #[must_use]
135 pub fn stop(
136 operation_id: OperationId,
137 actor: ActorRef,
138 job_execution_id: JobExecutionId,
139 expected_version: ExecutionVersion,
140 ) -> Self {
141 Self::build(
142 OperatorAction::Stop,
143 operation_id,
144 actor,
145 None,
146 OperatorTarget::Execution(job_execution_id),
147 Some(expected_version),
148 RequestArguments::None,
149 )
150 }
151
152 #[must_use]
154 pub fn abandon(
155 operation_id: OperationId,
156 actor: ActorRef,
157 reason: ReasonCode,
158 job_execution_id: JobExecutionId,
159 expected_version: ExecutionVersion,
160 ) -> Self {
161 Self::build(
162 OperatorAction::Abandon,
163 operation_id,
164 actor,
165 Some(reason),
166 OperatorTarget::Execution(job_execution_id),
167 Some(expected_version),
168 RequestArguments::None,
169 )
170 }
171
172 #[must_use]
174 pub fn recover(
175 operation_id: OperationId,
176 actor: ActorRef,
177 reason: ReasonCode,
178 directive: RecoveryDirective,
179 proposal: &RecoveryProposal,
180 ) -> Self {
181 let job_execution_id = proposal.evidence().execution_id();
182 let expected_version = proposal.observed_version();
183 let evidence_digest = *proposal.digest();
184 Self::build(
185 OperatorAction::Recover,
186 operation_id,
187 actor,
188 Some(reason),
189 OperatorTarget::Execution(job_execution_id),
190 Some(expected_version),
191 RequestArguments::Recovery {
192 directive,
193 evidence_digest,
194 unknown_commit: proposal.evidence().unknown_commit(),
195 },
196 )
197 }
198
199 fn build(
200 action: OperatorAction,
201 operation_id: OperationId,
202 actor: ActorRef,
203 reason: Option<ReasonCode>,
204 target: OperatorTarget,
205 expected_version: Option<ExecutionVersion>,
206 arguments: RequestArguments,
207 ) -> Self {
208 let digest = request_digest(
209 action,
210 &target.identity(),
211 expected_version,
212 reason.as_ref(),
213 &arguments,
214 );
215 Self {
216 action,
217 operation_id,
218 actor,
219 reason,
220 target,
221 expected_version,
222 arguments,
223 digest,
224 }
225 }
226
227 #[must_use]
229 pub const fn action(&self) -> OperatorAction {
230 self.action
231 }
232
233 #[must_use]
235 pub const fn authorization_class(&self) -> AuthorizationClass {
236 self.action.authorization_class()
237 }
238
239 #[must_use]
241 pub const fn operation_id(&self) -> &OperationId {
242 &self.operation_id
243 }
244
245 #[must_use]
247 pub const fn actor(&self) -> &ActorRef {
248 &self.actor
249 }
250
251 #[must_use]
253 pub const fn reason(&self) -> Option<&ReasonCode> {
254 self.reason.as_ref()
255 }
256
257 #[must_use]
259 pub const fn expected_version(&self) -> Option<ExecutionVersion> {
260 self.expected_version
261 }
262
263 #[must_use]
265 pub const fn digest(&self) -> &RequestDigest {
266 &self.digest
267 }
268
269 #[must_use]
271 pub const fn job_execution_id(&self) -> Option<JobExecutionId> {
272 match self.target {
273 OperatorTarget::Execution(id) => Some(id),
274 _ => None,
275 }
276 }
277
278 #[must_use]
280 pub const fn job_instance_id(&self) -> Option<JobInstanceId> {
281 match self.target {
282 OperatorTarget::Instance(id) => Some(id),
283 _ => None,
284 }
285 }
286
287 #[must_use]
292 pub fn job_instance_key(&self) -> Option<&JobInstanceKey> {
293 match &self.target {
294 OperatorTarget::InstanceKey(key) => Some(key),
295 _ => None,
296 }
297 }
298
299 #[doc(hidden)]
301 #[must_use]
302 pub fn definition(&self) -> Option<&DefinitionIdentity> {
303 match &self.arguments {
304 RequestArguments::Definition(definition) => Some(definition),
305 _ => None,
306 }
307 }
308
309 #[doc(hidden)]
311 #[must_use]
312 pub fn recovery_request(&self) -> Option<Result<RecoveryRequest, RecoveryRequestError>> {
313 let RequestArguments::Recovery {
314 directive,
315 evidence_digest,
316 ..
317 } = &self.arguments
318 else {
319 return None;
320 };
321 let expected_version = self.expected_version?;
322 let reason = self.reason.as_ref()?;
323 Some(match directive {
324 RecoveryDirective::Abandon => RecoveryRequest::abandon(
325 expected_version,
326 reason.as_str(),
327 self.actor.as_str(),
328 *evidence_digest,
329 ),
330 RecoveryDirective::MarkFailed(failure) => RecoveryRequest::mark_failed(
331 expected_version,
332 reason.as_str(),
333 self.actor.as_str(),
334 *evidence_digest,
335 failure.category(),
336 failure.failure_id(),
337 ),
338 })
339 }
340
341 #[doc(hidden)]
343 #[must_use]
344 pub fn recovery_guard(&self) -> Option<(RecoveryDirective, bool)> {
345 match &self.arguments {
346 RequestArguments::Recovery {
347 directive,
348 unknown_commit,
349 ..
350 } => Some((*directive, *unknown_commit)),
351 _ => None,
352 }
353 }
354}
355
356#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
358#[non_exhaustive]
359pub enum OperatorOutcomeClass {
360 Applied,
362 Replayed,
364 Rejected,
366}
367
368impl OperatorOutcomeClass {
369 #[must_use]
371 pub const fn as_str(self) -> &'static str {
372 match self {
373 Self::Applied => "APPLIED",
374 Self::Replayed => "REPLAYED",
375 Self::Rejected => "REJECTED",
376 }
377 }
378}
379
380#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385#[non_exhaustive]
386pub enum OperatorRejection {
387 OptimisticConflict {
389 current: ExecutionVersion,
391 },
392 InvalidState {
394 status: BatchStatus,
396 },
397 InstanceCompleted,
399 InstanceAbandoned,
401 ExecutionAlreadyActive {
403 execution_id: JobExecutionId,
405 status: BatchStatus,
407 },
408 IncompatibleDefinition,
410 RestartWithoutPriorAttempt,
412 StartLimitExceeded,
414 UnresolvedRecoveryRequired,
416 ExecutionNotFound,
418 InstanceNotFound,
420 UnsupportedAction,
427}
428
429impl OperatorRejection {
430 #[must_use]
432 pub const fn as_str(self) -> &'static str {
433 match self {
434 Self::OptimisticConflict { .. } => "OPTIMISTIC_CONFLICT",
435 Self::InvalidState { .. } => "INVALID_STATE",
436 Self::InstanceCompleted => "INSTANCE_COMPLETED",
437 Self::InstanceAbandoned => "INSTANCE_ABANDONED",
438 Self::ExecutionAlreadyActive { .. } => "EXECUTION_ALREADY_ACTIVE",
439 Self::IncompatibleDefinition => "INCOMPATIBLE_DEFINITION",
440 Self::RestartWithoutPriorAttempt => "RESTART_WITHOUT_PRIOR_ATTEMPT",
441 Self::StartLimitExceeded => "START_LIMIT_EXCEEDED",
442 Self::UnresolvedRecoveryRequired => "UNRESOLVED_RECOVERY_REQUIRED",
443 Self::ExecutionNotFound => "EXECUTION_NOT_FOUND",
444 Self::InstanceNotFound => "INSTANCE_NOT_FOUND",
445 Self::UnsupportedAction => "UNSUPPORTED_ACTION",
446 }
447 }
448
449 #[doc(hidden)]
451 #[must_use]
452 pub fn from_repository(error: &RepositoryError) -> Option<Self> {
453 match error {
454 RepositoryError::CompletedInstance { .. } => Some(Self::InstanceCompleted),
455 RepositoryError::AbandonedInstance { .. } => Some(Self::InstanceAbandoned),
456 RepositoryError::ExecutionAlreadyActive {
457 execution_id,
458 status,
459 ..
460 } => Some(Self::ExecutionAlreadyActive {
461 execution_id: *execution_id,
462 status: *status,
463 }),
464 RepositoryError::IncompatibleDefinition { .. }
465 | RepositoryError::RestartStateNotFound { .. }
466 | RepositoryError::InvalidDefinitionUpgrade { .. } => {
467 Some(Self::IncompatibleDefinition)
468 }
469 RepositoryError::StartLimitExceeded { .. } => Some(Self::StartLimitExceeded),
470 RepositoryError::JobExecutionNotFound { .. } => Some(Self::ExecutionNotFound),
471 RepositoryError::JobInstanceNotFound { .. } => Some(Self::InstanceNotFound),
472
473 RepositoryError::Lifecycle(LifecycleError::StaleVersion { actual, .. }) => {
474 Some(Self::OptimisticConflict { current: *actual })
475 }
476 RepositoryError::Lifecycle(
477 LifecycleError::IllegalTransition { from, .. }
478 | LifecycleError::RestartRequiresNewAttempt { from },
479 ) => Some(Self::InvalidState { status: *from }),
480 RepositoryError::RecoveryNotAllowed { status, .. }
481 | RepositoryError::Lifecycle(LifecycleError::NotRestartable { status }) => {
482 Some(Self::InvalidState { status: *status })
483 }
484 _ => None,
485 }
486 }
487}
488
489impl fmt::Display for OperatorRejection {
490 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
491 formatter.write_str(self.as_str())
492 }
493}
494
495#[derive(Clone, Debug, Eq, PartialEq)]
497pub struct OperatorRecord {
498 id: OperatorRequestId,
499 action: OperatorAction,
500 operation_id: OperationId,
501 actor: ActorRef,
502 reason: Option<ReasonCode>,
503 digest: RequestDigest,
504 job_instance_id: Option<JobInstanceId>,
505 job_execution_id: Option<JobExecutionId>,
506 observed_version: Option<ExecutionVersion>,
507 prior_status: Option<BatchStatus>,
508 result_status: Option<BatchStatus>,
509 outcome: OperatorOutcomeClass,
510 rejection: Option<OperatorRejection>,
511 requested_at: SystemTime,
512}
513
514impl OperatorRecord {
515 #[must_use]
517 pub fn from_parts(id: OperatorRequestId, draft: OperatorRecordDraft) -> Self {
518 Self {
519 id,
520 action: draft.action,
521 operation_id: draft.operation_id,
522 actor: draft.actor,
523 reason: draft.reason,
524 digest: draft.digest,
525 job_instance_id: draft.job_instance_id,
526 job_execution_id: draft.job_execution_id,
527 observed_version: draft.observed_version,
528 prior_status: draft.prior_status,
529 result_status: draft.result_status,
530 outcome: draft.outcome,
531 rejection: draft.rejection,
532 requested_at: draft.requested_at,
533 }
534 }
535
536 #[must_use]
538 pub const fn id(&self) -> OperatorRequestId {
539 self.id
540 }
541
542 #[must_use]
544 pub const fn action(&self) -> OperatorAction {
545 self.action
546 }
547
548 #[must_use]
550 pub const fn operation_id(&self) -> &OperationId {
551 &self.operation_id
552 }
553
554 #[must_use]
556 pub const fn actor(&self) -> &ActorRef {
557 &self.actor
558 }
559
560 #[must_use]
562 pub const fn reason(&self) -> Option<&ReasonCode> {
563 self.reason.as_ref()
564 }
565
566 #[must_use]
568 pub const fn digest(&self) -> &RequestDigest {
569 &self.digest
570 }
571
572 #[must_use]
574 pub const fn job_instance_id(&self) -> Option<JobInstanceId> {
575 self.job_instance_id
576 }
577
578 #[must_use]
580 pub const fn job_execution_id(&self) -> Option<JobExecutionId> {
581 self.job_execution_id
582 }
583
584 #[must_use]
586 pub const fn observed_version(&self) -> Option<ExecutionVersion> {
587 self.observed_version
588 }
589
590 #[must_use]
592 pub const fn prior_status(&self) -> Option<BatchStatus> {
593 self.prior_status
594 }
595
596 #[must_use]
598 pub const fn result_status(&self) -> Option<BatchStatus> {
599 self.result_status
600 }
601
602 #[must_use]
604 pub const fn outcome(&self) -> OperatorOutcomeClass {
605 self.outcome
606 }
607
608 #[must_use]
610 pub const fn rejection(&self) -> Option<OperatorRejection> {
611 self.rejection
612 }
613
614 #[must_use]
616 pub const fn requested_at(&self) -> SystemTime {
617 self.requested_at
618 }
619}
620
621#[derive(Clone, Debug, Eq, PartialEq)]
623pub struct OperatorRecordDraft {
624 action: OperatorAction,
625 operation_id: OperationId,
626 actor: ActorRef,
627 reason: Option<ReasonCode>,
628 digest: RequestDigest,
629 job_instance_id: Option<JobInstanceId>,
630 job_execution_id: Option<JobExecutionId>,
631 observed_version: Option<ExecutionVersion>,
632 prior_status: Option<BatchStatus>,
633 result_status: Option<BatchStatus>,
634 outcome: OperatorOutcomeClass,
635 rejection: Option<OperatorRejection>,
636 requested_at: SystemTime,
637}
638
639impl OperatorRecordDraft {
640 #[must_use]
646 pub fn applied(
647 request: &OperatorRequest,
648 job_instance_id: Option<JobInstanceId>,
649 job_execution_id: Option<JobExecutionId>,
650 prior_status: Option<BatchStatus>,
651 result_status: Option<BatchStatus>,
652 requested_at: SystemTime,
653 ) -> Self {
654 Self {
655 action: request.action(),
656 operation_id: request.operation_id().clone(),
657 actor: request.actor().clone(),
658 reason: request.reason().cloned(),
659 digest: *request.digest(),
660 job_instance_id,
661 job_execution_id,
662 observed_version: request.expected_version(),
663 prior_status,
664 result_status,
665 outcome: OperatorOutcomeClass::Applied,
666 rejection: None,
667 requested_at,
668 }
669 }
670
671 #[must_use]
676 pub fn rejected(
677 request: &OperatorRequest,
678 rejection: OperatorRejection,
679 requested_at: SystemTime,
680 ) -> Self {
681 Self {
682 action: request.action(),
683 operation_id: request.operation_id().clone(),
684 actor: request.actor().clone(),
685 reason: request.reason().cloned(),
686 digest: *request.digest(),
687 job_instance_id: request.job_instance_id(),
688 job_execution_id: request.job_execution_id(),
689 observed_version: request.expected_version(),
690 prior_status: None,
691 result_status: None,
692 outcome: OperatorOutcomeClass::Rejected,
693 rejection: Some(rejection),
694 requested_at,
695 }
696 }
697
698 #[must_use]
703 #[allow(clippy::too_many_arguments)]
704 pub const fn from_durable(
705 action: OperatorAction,
706 operation_id: OperationId,
707 actor: ActorRef,
708 reason: Option<ReasonCode>,
709 digest: RequestDigest,
710 job_instance_id: Option<JobInstanceId>,
711 job_execution_id: Option<JobExecutionId>,
712 observed_version: Option<ExecutionVersion>,
713 prior_status: Option<BatchStatus>,
714 result_status: Option<BatchStatus>,
715 outcome: OperatorOutcomeClass,
716 rejection: Option<OperatorRejection>,
717 requested_at: SystemTime,
718 ) -> Self {
719 Self {
720 action,
721 operation_id,
722 actor,
723 reason,
724 digest,
725 job_instance_id,
726 job_execution_id,
727 observed_version,
728 prior_status,
729 result_status,
730 outcome,
731 rejection,
732 requested_at,
733 }
734 }
735
736 #[must_use]
738 pub const fn action(&self) -> OperatorAction {
739 self.action
740 }
741
742 #[must_use]
744 pub const fn operation_id(&self) -> &OperationId {
745 &self.operation_id
746 }
747
748 #[must_use]
750 pub const fn actor(&self) -> &ActorRef {
751 &self.actor
752 }
753
754 #[must_use]
756 pub const fn reason(&self) -> Option<&ReasonCode> {
757 self.reason.as_ref()
758 }
759
760 #[must_use]
762 pub const fn digest(&self) -> &RequestDigest {
763 &self.digest
764 }
765
766 #[must_use]
768 pub const fn job_instance_id(&self) -> Option<JobInstanceId> {
769 self.job_instance_id
770 }
771
772 #[must_use]
774 pub const fn job_execution_id(&self) -> Option<JobExecutionId> {
775 self.job_execution_id
776 }
777
778 #[must_use]
780 pub const fn observed_version(&self) -> Option<ExecutionVersion> {
781 self.observed_version
782 }
783
784 #[must_use]
786 pub const fn prior_status(&self) -> Option<BatchStatus> {
787 self.prior_status
788 }
789
790 #[must_use]
792 pub const fn result_status(&self) -> Option<BatchStatus> {
793 self.result_status
794 }
795
796 #[must_use]
798 pub const fn outcome(&self) -> OperatorOutcomeClass {
799 self.outcome
800 }
801
802 #[must_use]
804 pub const fn rejection(&self) -> Option<OperatorRejection> {
805 self.rejection
806 }
807
808 #[must_use]
810 pub const fn requested_at(&self) -> SystemTime {
811 self.requested_at
812 }
813}