1use 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#[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 #[must_use]
48 pub const fn class(&self) -> OperatorOutcomeClass {
49 self.class
50 }
51
52 #[must_use]
54 pub const fn record(&self) -> &OperatorRecord {
55 &self.record
56 }
57
58 #[must_use]
62 pub const fn execution(&self) -> Option<&JobExecution> {
63 self.execution.as_ref()
64 }
65
66 #[must_use]
68 pub const fn rejection(&self) -> Option<OperatorRejection> {
69 self.record.rejection()
70 }
71
72 #[must_use]
76 pub const fn changed_state(&self) -> bool {
77 self.changed
78 }
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
83#[non_exhaustive]
84pub enum OperatorError {
85 OperationIdConflict {
87 action: OperatorAction,
89 operation_id: OperationId,
91 },
92 OperationOutcomeUnknown,
98 InvalidRecoveryRequest(RecoveryRequestError),
100 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#[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 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 #[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 pub const fn repository(&self) -> &R {
182 &self.repository
183 }
184
185 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 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 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 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 _ => 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 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 _ => 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 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}