1use std::path::{Path, PathBuf};
4
5use a3s_box_core::{ExecutionGeneration, ExecutionId, ExecutionSnapshotId, OperationId};
6use thiserror::Error;
7
8use crate::{
9 BoxRecord, BoxStateStore, ManagedExecutionOperation, ManagedExecutionState,
10 ManagedResourceUpdateCompletion, ManagedRestartCompletion, ManagedRestartOutcome,
11};
12
13#[derive(Debug, Clone)]
15pub struct ManagedExecutionStore {
16 path: PathBuf,
17}
18
19#[derive(Debug, Clone)]
21pub enum ManagedExecutionReservation {
22 Reserved(BoxRecord),
24 Existing(BoxRecord),
26}
27
28impl ManagedExecutionReservation {
29 pub const fn is_new(&self) -> bool {
30 matches!(self, Self::Reserved(_))
31 }
32
33 pub fn record(&self) -> &BoxRecord {
34 match self {
35 Self::Reserved(record) | Self::Existing(record) => record,
36 }
37 }
38
39 pub fn into_record(self) -> BoxRecord {
40 match self {
41 Self::Reserved(record) | Self::Existing(record) => record,
42 }
43 }
44}
45
46#[derive(Debug, Error)]
48pub enum ManagedExecutionStoreError {
49 #[error("managed execution state I/O failed: {0}")]
50 Io(#[from] std::io::Error),
51 #[error("managed execution not found: {0}")]
52 NotFound(ExecutionId),
53 #[error("execution record is not managed: {0}")]
54 Unmanaged(ExecutionId),
55 #[error("managed execution conflict for {execution_id}: {message}")]
56 Conflict {
57 execution_id: ExecutionId,
58 message: String,
59 },
60 #[error("invalid managed execution record: {0}")]
61 InvalidRecord(String),
62 #[error("invalid managed execution transition for {execution_id}: {from} -> {to}")]
63 InvalidTransition {
64 execution_id: ExecutionId,
65 from: ManagedExecutionState,
66 to: ManagedExecutionState,
67 },
68}
69
70pub type ManagedExecutionStoreResult<T> = std::result::Result<T, ManagedExecutionStoreError>;
71
72impl ManagedExecutionStore {
73 pub fn new(path: impl Into<PathBuf>) -> Self {
74 Self { path: path.into() }
75 }
76
77 pub fn path(&self) -> &Path {
78 &self.path
79 }
80
81 pub fn get(
83 &self,
84 execution_id: &ExecutionId,
85 ) -> ManagedExecutionStoreResult<Option<BoxRecord>> {
86 let store = BoxStateStore::load_readonly(&self.path)?;
87 let Some(record) = store.find_by_id(execution_id.as_str()).cloned() else {
88 return Ok(None);
89 };
90 if record.managed_execution.is_none() {
91 return Err(ManagedExecutionStoreError::Unmanaged(execution_id.clone()));
92 }
93 Ok(Some(record))
94 }
95
96 pub fn list(&self) -> ManagedExecutionStoreResult<Vec<BoxRecord>> {
103 let store = BoxStateStore::load_readonly(&self.path)?;
104 Ok(store
105 .records()
106 .iter()
107 .filter(|record| record.managed_execution.is_some())
108 .cloned()
109 .collect())
110 }
111
112 pub fn get_by_operation_id(
114 &self,
115 operation_id: &OperationId,
116 ) -> ManagedExecutionStoreResult<Option<BoxRecord>> {
117 let store = BoxStateStore::load_readonly(&self.path)?;
118 Ok(store.find_by_operation_id(operation_id).cloned())
119 }
120
121 pub fn reserve(
127 &self,
128 mut record: BoxRecord,
129 ) -> ManagedExecutionStoreResult<ManagedExecutionReservation> {
130 let execution_id = validate_new_record(&record)?;
131 record.status = ManagedExecutionState::Created.as_status().to_string();
132 let incoming_metadata = record
133 .managed_execution
134 .as_ref()
135 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?
136 .clone();
137
138 BoxStateStore::transact(&self.path, move |store| {
139 if let Some(existing) = store
140 .find_by_operation_id(&incoming_metadata.operation_id)
141 .cloned()
142 {
143 let existing_id = ExecutionId::new(existing.id.clone()).map_err(|error| {
144 ManagedExecutionStoreError::InvalidRecord(error.to_string())
145 })?;
146 let existing_metadata = existing
147 .managed_execution
148 .as_ref()
149 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(existing_id.clone()))?;
150 if !same_reservation_intent(existing_metadata, &incoming_metadata)? {
151 return Err(ManagedExecutionStoreError::Conflict {
152 execution_id: existing_id,
153 message: format!(
154 "operation {} was already reserved with different creation intent",
155 incoming_metadata.operation_id
156 ),
157 });
158 }
159 return Ok(ManagedExecutionReservation::Existing(existing));
160 }
161
162 if store.find_by_id(execution_id.as_str()).is_some() {
163 return Err(ManagedExecutionStoreError::Conflict {
164 execution_id,
165 message: "execution ID is already present".to_string(),
166 });
167 }
168
169 store.records_mut().push(record.clone());
170 Ok(ManagedExecutionReservation::Reserved(record))
171 })
172 }
173
174 pub fn begin_remove(
180 &self,
181 execution_id: &ExecutionId,
182 expected_generation: ExecutionGeneration,
183 ) -> ManagedExecutionStoreResult<Option<BoxRecord>> {
184 let execution_id = execution_id.clone();
185 BoxStateStore::transact(&self.path, move |store| {
186 let Some(record) = store.find_by_id_mut(execution_id.as_str()) else {
187 return Ok(None);
188 };
189 let state = record
190 .managed_state()
191 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
192 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
193 let metadata = record
194 .managed_execution
195 .as_mut()
196 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
197 if metadata.generation != expected_generation {
198 return Err(ManagedExecutionStoreError::Conflict {
199 execution_id: execution_id.clone(),
200 message: format!(
201 "expected generation {}, found {}",
202 expected_generation.get(),
203 metadata.generation.get()
204 ),
205 });
206 }
207 match state {
208 ManagedExecutionState::Removing => Ok(Some(record.clone())),
209 ManagedExecutionState::Created
210 | ManagedExecutionState::Stopped
211 | ManagedExecutionState::Failed => {
212 record.status = ManagedExecutionState::Removing.as_status().to_string();
213 metadata.pending_operation = Some(ManagedExecutionOperation::Remove);
214 Ok(Some(record.clone()))
215 }
216 _ => Err(ManagedExecutionStoreError::Conflict {
217 execution_id: execution_id.clone(),
218 message: format!("cannot remove execution in state {state}"),
219 }),
220 }
221 })
222 }
223
224 pub fn finish_remove(
226 &self,
227 execution_id: &ExecutionId,
228 expected_generation: ExecutionGeneration,
229 ) -> ManagedExecutionStoreResult<bool> {
230 let execution_id = execution_id.clone();
231 BoxStateStore::transact(&self.path, move |store| {
232 let Some(record) = store.find_by_id(execution_id.as_str()) else {
233 return Ok(false);
234 };
235 let state = record
236 .managed_state()
237 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
238 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
239 let metadata = record
240 .managed_execution
241 .as_ref()
242 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
243 if metadata.generation != expected_generation
244 || state != ManagedExecutionState::Removing
245 {
246 return Err(ManagedExecutionStoreError::Conflict {
247 execution_id: execution_id.clone(),
248 message: format!(
249 "expected removing generation {}, found {state} generation {}",
250 expected_generation.get(),
251 metadata.generation.get()
252 ),
253 });
254 }
255 Ok(store.remove_by_id(execution_id.as_str()))
256 })
257 }
258
259 pub fn transition(
265 &self,
266 execution_id: &ExecutionId,
267 expected_generation: ExecutionGeneration,
268 expected_state: ManagedExecutionState,
269 next_state: ManagedExecutionState,
270 ) -> ManagedExecutionStoreResult<BoxRecord> {
271 self.transition_with(
272 execution_id,
273 expected_generation,
274 expected_state,
275 next_state,
276 |_| {},
277 )
278 }
279
280 pub fn transition_with(
283 &self,
284 execution_id: &ExecutionId,
285 expected_generation: ExecutionGeneration,
286 expected_state: ManagedExecutionState,
287 next_state: ManagedExecutionState,
288 update: impl FnOnce(&mut BoxRecord),
289 ) -> ManagedExecutionStoreResult<BoxRecord> {
290 let execution_id = execution_id.clone();
291 BoxStateStore::transact(&self.path, move |store| {
292 let record = store
293 .find_by_id_mut(execution_id.as_str())
294 .ok_or_else(|| ManagedExecutionStoreError::NotFound(execution_id.clone()))?;
295 let actual_state = record
296 .managed_state()
297 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
298 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
299 let metadata = record
300 .managed_execution
301 .as_ref()
302 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
303
304 if metadata.generation != expected_generation || actual_state != expected_state {
305 return Err(ManagedExecutionStoreError::Conflict {
306 execution_id: execution_id.clone(),
307 message: format!(
308 "expected {expected_state} generation {}, found {actual_state} generation {}",
309 expected_generation.get(),
310 metadata.generation.get()
311 ),
312 });
313 }
314
315 let next_generation = transition_generation(
316 &execution_id,
317 expected_state,
318 next_state,
319 expected_generation,
320 )?;
321 let original_metadata = record
322 .managed_execution
323 .as_ref()
324 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?
325 .clone();
326 update(record);
327 if record.id != execution_id.as_str() {
328 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
329 "transition changed execution ID {execution_id}"
330 )));
331 }
332 let updated_metadata = record
333 .managed_execution
334 .as_ref()
335 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
336 if updated_metadata.operation_id != original_metadata.operation_id
337 || updated_metadata.creation_intent_digest
338 != original_metadata.creation_intent_digest
339 || !same_creation_intent(updated_metadata, &original_metadata)?
340 {
341 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
342 "transition changed creation identity for {execution_id}"
343 )));
344 }
345 record.status = next_state.as_status().to_string();
346 let metadata = record
347 .managed_execution
348 .as_mut()
349 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
350 metadata.generation = next_generation;
351 if expected_state == ManagedExecutionState::RestartStarting
352 && matches!(
353 next_state,
354 ManagedExecutionState::Running
355 | ManagedExecutionState::Stopped
356 | ManagedExecutionState::Failed
357 )
358 {
359 let Some(ManagedExecutionOperation::Restart {
360 operation_id,
361 source_generation,
362 stop_timeout_secs,
363 ..
364 }) = metadata.pending_operation.as_ref()
365 else {
366 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
367 "restart completion for {execution_id} has no persisted restart intent"
368 )));
369 };
370 let outcome = if next_state == ManagedExecutionState::Running {
371 ManagedRestartOutcome::Running
372 } else if next_state == ManagedExecutionState::Stopped {
373 ManagedRestartOutcome::Stopped
374 } else if next_state == ManagedExecutionState::Failed {
375 ManagedRestartOutcome::Failed
376 } else {
377 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
378 "restart completion for {execution_id} has non-terminal state {next_state}"
379 )));
380 };
381 metadata.last_restart = Some(ManagedRestartCompletion {
382 operation_id: operation_id.clone(),
383 source_generation: *source_generation,
384 target_generation: next_generation,
385 outcome,
386 stop_timeout_secs: *stop_timeout_secs,
387 });
388 }
389 metadata.pending_operation = match next_state {
390 ManagedExecutionState::Starting => Some(ManagedExecutionOperation::Start),
391 ManagedExecutionState::Pausing => match metadata.pending_operation.take() {
392 Some(operation @ ManagedExecutionOperation::Pause { .. }) => Some(operation),
393 _ => Some(ManagedExecutionOperation::Pause {
394 keep_memory: false,
395 operation_id: None,
396 }),
397 },
398 ManagedExecutionState::Resuming => match metadata.pending_operation.take() {
399 Some(operation @ ManagedExecutionOperation::Resume { .. }) => Some(operation),
400 _ => Some(ManagedExecutionOperation::Resume { operation_id: None }),
401 },
402 ManagedExecutionState::UpdatingResources => {
403 match metadata.pending_operation.take() {
404 Some(operation @ ManagedExecutionOperation::UpdateResources { .. }) => {
405 Some(operation)
406 }
407 _ => {
408 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
409 "resource update transition for {execution_id} has no persisted intent"
410 )))
411 }
412 }
413 }
414 ManagedExecutionState::Snapshotting => match metadata.pending_operation.take() {
415 Some(operation @ ManagedExecutionOperation::Snapshot { .. }) => Some(operation),
416 _ => {
417 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
418 "snapshot transition for {execution_id} has no persisted snapshot intent"
419 )))
420 }
421 },
422 ManagedExecutionState::Killing => match metadata.pending_operation.take() {
423 Some(operation @ ManagedExecutionOperation::Kill { .. }) => Some(operation),
424 _ => Some(ManagedExecutionOperation::Kill {
425 signal: None,
426 timeout_secs: None,
427 }),
428 },
429 ManagedExecutionState::Removing => Some(ManagedExecutionOperation::Remove),
430 ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting => {
431 match metadata.pending_operation.take() {
432 Some(operation @ ManagedExecutionOperation::Restart { .. }) => {
433 Some(operation)
434 }
435 _ => {
436 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
437 "restart transition for {execution_id} has no persisted restart intent"
438 )))
439 }
440 }
441 }
442 ManagedExecutionState::Creating
443 | ManagedExecutionState::Created
444 | ManagedExecutionState::Running
445 | ManagedExecutionState::Paused
446 | ManagedExecutionState::Stopped
447 | ManagedExecutionState::Failed => None,
448 };
449 Ok(record.clone())
450 })
451 }
452
453 pub fn mark_snapshot_freezer_applied(
459 &self,
460 execution_id: &ExecutionId,
461 expected_generation: ExecutionGeneration,
462 expected_snapshot_id: &ExecutionSnapshotId,
463 expected_operation_id: Option<&OperationId>,
464 ) -> ManagedExecutionStoreResult<BoxRecord> {
465 let execution_id = execution_id.clone();
466 let expected_snapshot_id = expected_snapshot_id.clone();
467 let expected_operation_id = expected_operation_id.cloned();
468 BoxStateStore::transact(&self.path, move |store| {
469 let record = store
470 .find_by_id_mut(execution_id.as_str())
471 .ok_or_else(|| ManagedExecutionStoreError::NotFound(execution_id.clone()))?;
472 let state = record
473 .managed_state()
474 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
475 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
476 let metadata = record
477 .managed_execution
478 .as_mut()
479 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
480 if state != ManagedExecutionState::Snapshotting
481 || metadata.generation != expected_generation
482 {
483 return Err(ManagedExecutionStoreError::Conflict {
484 execution_id: execution_id.clone(),
485 message: format!(
486 "expected snapshotting generation {}, found {state} generation {}",
487 expected_generation.get(),
488 metadata.generation.get()
489 ),
490 });
491 }
492 match metadata.pending_operation.as_mut() {
493 Some(ManagedExecutionOperation::Snapshot {
494 snapshot_id,
495 source_state: ManagedExecutionState::Running,
496 operation_id,
497 freezer_applied,
498 }) if snapshot_id == &expected_snapshot_id
499 && operation_id == &expected_operation_id =>
500 {
501 *freezer_applied = true;
502 }
503 _ => {
504 return Err(ManagedExecutionStoreError::Conflict {
505 execution_id: execution_id.clone(),
506 message: format!(
507 "snapshot freezer phase does not match claim {expected_snapshot_id}"
508 ),
509 })
510 }
511 }
512 Ok(record.clone())
513 })
514 }
515
516 pub fn finish_resource_update(
522 &self,
523 execution_id: &ExecutionId,
524 expected_generation: ExecutionGeneration,
525 ) -> ManagedExecutionStoreResult<BoxRecord> {
526 let execution_id = execution_id.clone();
527 BoxStateStore::transact(&self.path, move |store| {
528 let record = store
529 .find_by_id_mut(execution_id.as_str())
530 .ok_or_else(|| ManagedExecutionStoreError::NotFound(execution_id.clone()))?;
531 let state = record
532 .managed_state()
533 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
534 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
535 let metadata = record
536 .managed_execution
537 .as_ref()
538 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
539 if state != ManagedExecutionState::UpdatingResources
540 || metadata.generation != expected_generation
541 {
542 return Err(ManagedExecutionStoreError::Conflict {
543 execution_id: execution_id.clone(),
544 message: format!(
545 "expected updating_resources generation {}, found {state} generation {}",
546 expected_generation.get(),
547 metadata.generation.get()
548 ),
549 });
550 }
551 let (operation_id, update) = match metadata.pending_operation.as_ref() {
552 Some(ManagedExecutionOperation::UpdateResources {
553 operation_id,
554 update,
555 }) => (operation_id.clone(), update.clone()),
556 _ => {
557 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
558 "resource update completion for {execution_id} has no persisted intent"
559 )))
560 }
561 };
562
563 let metadata = record
564 .managed_execution
565 .as_mut()
566 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
567 update.apply_to(&mut metadata.request.config.resource_limits);
568 metadata.plan = a3s_box_core::resolve_execution(&metadata.request.config)
569 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
570 record.resource_limits = metadata.request.config.resource_limits.clone();
571 metadata.last_resource_update = Some(ManagedResourceUpdateCompletion {
572 operation_id,
573 generation: expected_generation,
574 update,
575 });
576 metadata.pending_operation = None;
577 record.status = ManagedExecutionState::Running.as_status().to_string();
578 Ok(record.clone())
579 })
580 }
581}
582
583fn validate_new_record(record: &BoxRecord) -> ManagedExecutionStoreResult<ExecutionId> {
584 let execution_id = ExecutionId::new(record.id.clone())
585 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
586 let metadata = record
587 .managed_execution
588 .as_ref()
589 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
590 metadata
591 .validate()
592 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
593 let state = record
594 .managed_state()
595 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
596 .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
597 if state != ManagedExecutionState::Created {
598 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
599 "new execution {execution_id} must be created, found {state}"
600 )));
601 }
602 if metadata.generation != ExecutionGeneration::INITIAL {
603 return Err(ManagedExecutionStoreError::InvalidRecord(format!(
604 "new execution {execution_id} must start at generation {}",
605 ExecutionGeneration::INITIAL.get()
606 )));
607 }
608 Ok(execution_id)
609}
610
611fn same_creation_intent(
612 left: &crate::ManagedExecutionMetadata,
613 right: &crate::ManagedExecutionMetadata,
614) -> ManagedExecutionStoreResult<bool> {
615 let left_request = serde_json::to_value(&left.request)
616 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
617 let right_request = serde_json::to_value(&right.request)
618 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
619 Ok(left_request == right_request && left.plan == right.plan)
620}
621
622fn same_reservation_intent(
623 left: &crate::ManagedExecutionMetadata,
624 right: &crate::ManagedExecutionMetadata,
625) -> ManagedExecutionStoreResult<bool> {
626 match (
627 left.creation_intent_digest.as_deref(),
628 right.creation_intent_digest.as_deref(),
629 ) {
630 (Some(left_digest), Some(right_digest)) => {
631 Ok(left_digest == right_digest && same_immutable_reservation_shape(left, right)?)
632 }
633 _ => same_creation_intent(left, right),
636 }
637}
638
639fn same_immutable_reservation_shape(
640 left: &crate::ManagedExecutionMetadata,
641 right: &crate::ManagedExecutionMetadata,
642) -> ManagedExecutionStoreResult<bool> {
643 let mut left_request = left.request.clone();
644 let mut right_request = right.request.clone();
645 clear_mutable_resource_limits(&mut left_request.config.resource_limits);
646 clear_mutable_resource_limits(&mut right_request.config.resource_limits);
647 let left_request = serde_json::to_value(left_request)
648 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
649 let right_request = serde_json::to_value(right_request)
650 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
651 Ok(left_request == right_request && left.plan == right.plan)
652}
653
654fn clear_mutable_resource_limits(limits: &mut a3s_box_core::ResourceLimits) {
655 limits.memory_reservation = None;
656 limits.memory_swap = None;
657 limits.pids_limit = None;
658 limits.cpu_shares = None;
659 limits.cpu_quota = None;
660 limits.cpu_period = None;
661 limits.cpuset_cpus = None;
662}
663
664fn transition_generation(
665 execution_id: &ExecutionId,
666 from: ManagedExecutionState,
667 to: ManagedExecutionState,
668 current: ExecutionGeneration,
669) -> ManagedExecutionStoreResult<ExecutionGeneration> {
670 use ManagedExecutionState::{
671 Created, Creating, Failed, Killing, Paused, Pausing, RestartStarting, RestartStopping,
672 Resuming, Running, Snapshotting, Starting, Stopped, UpdatingResources,
673 };
674
675 let legal = matches!(
676 (from, to),
677 (Creating, Created | Starting | Killing | Stopped | Failed)
678 | (
679 Created,
680 Starting | Killing | RestartStopping | Stopped | Failed
681 )
682 | (
683 Starting,
684 Created | Creating | Running | Killing | Stopped | Failed
685 )
686 | (
687 Running,
688 Pausing
689 | UpdatingResources
690 | Snapshotting
691 | Killing
692 | RestartStopping
693 | Stopped
694 | Failed
695 )
696 | (Pausing, Paused | Running | Killing | Stopped | Failed)
697 | (
698 Paused,
699 Resuming | Snapshotting | Killing | RestartStopping | Stopped | Failed
700 )
701 | (Resuming, Running | Paused | Killing | Stopped | Failed)
702 | (UpdatingResources, Running | Killing | Stopped | Failed)
703 | (Snapshotting, Running | Paused | Stopped | Failed)
704 | (Killing, Stopped | Failed)
705 | (Stopped | Failed, RestartStopping)
706 | (RestartStopping, RestartStarting)
707 | (RestartStarting, Running | Stopped | Failed)
708 );
709 if !legal {
710 return Err(ManagedExecutionStoreError::InvalidTransition {
711 execution_id: execution_id.clone(),
712 from,
713 to,
714 });
715 }
716
717 if matches!(
718 (from, to),
719 (Pausing, Paused) | (Resuming, Running) | (RestartStopping, RestartStarting)
720 ) {
721 let value = current.get().checked_add(1).ok_or_else(|| {
722 ManagedExecutionStoreError::InvalidRecord(format!(
723 "execution {execution_id} generation is exhausted"
724 ))
725 })?;
726 return ExecutionGeneration::new(value)
727 .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()));
728 }
729 Ok(current)
730}
731
732#[cfg(test)]
733mod tests {
734 #[cfg(unix)]
735 use std::sync::{Arc, Barrier};
736
737 use a3s_box_core::{CreateExecutionRequest, ExecutionIsolation, ExecutionSnapshotId};
738
739 use super::*;
740 use crate::ManagedExecutionMetadata;
741
742 fn managed_record(id: &str, operation: &str) -> BoxRecord {
743 let mut record: BoxRecord = serde_json::from_value(serde_json::json!({
744 "id": id,
745 "short_id": BoxRecord::make_short_id(id),
746 "name": format!("box-{id}"),
747 "image": "alpine:latest",
748 "isolation": "sandbox",
749 "status": "created",
750 "pid": null,
751 "cpus": 1,
752 "memory_mb": 128,
753 "volumes": [],
754 "env": {},
755 "cmd": ["sh"],
756 "box_dir": format!("/tmp/{id}"),
757 "console_log": format!("/tmp/{id}/console.log"),
758 "created_at": "2026-07-14T12:00:00Z",
759 "started_at": null,
760 "auto_remove": false
761 }))
762 .unwrap();
763 let config = a3s_box_core::BoxConfig {
764 image: "alpine:latest".to_string(),
765 isolation: ExecutionIsolation::Sandbox,
766 ..Default::default()
767 };
768 record.managed_execution = Some(
769 ManagedExecutionMetadata::new(
770 OperationId::new(operation).unwrap(),
771 ExecutionGeneration::INITIAL,
772 CreateExecutionRequest {
773 external_sandbox_id: "sandbox-1".to_string(),
774 config,
775 labels: Default::default(),
776 policy: Default::default(),
777 rootfs_snapshot_id: None,
778 },
779 )
780 .unwrap(),
781 );
782 record
783 }
784
785 #[test]
786 fn reservation_is_idempotent_for_the_same_full_request() {
787 let directory = tempfile::tempdir().unwrap();
788 let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
789
790 let first = store.reserve(managed_record("execution-1", "operation-1"));
791 let retry = store.reserve(managed_record("execution-2", "operation-1"));
792
793 assert!(first.unwrap().is_new());
794 let retry = retry.unwrap();
795 assert!(!retry.is_new());
796 assert_eq!(retry.record().id, "execution-1");
797 assert_eq!(
798 BoxStateStore::load(store.path()).unwrap().records().len(),
799 1
800 );
801 }
802
803 #[test]
804 fn reservation_rejects_operation_reuse_with_different_intent() {
805 let directory = tempfile::tempdir().unwrap();
806 let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
807 store
808 .reserve(managed_record("execution-1", "operation-1"))
809 .unwrap();
810 let mut conflicting = managed_record("execution-2", "operation-1");
811 conflicting
812 .managed_execution
813 .as_mut()
814 .unwrap()
815 .request
816 .external_sandbox_id = "sandbox-2".to_string();
817
818 let error = store.reserve(conflicting).unwrap_err();
819
820 assert!(matches!(error, ManagedExecutionStoreError::Conflict { .. }));
821 assert_eq!(
822 BoxStateStore::load(store.path()).unwrap().records().len(),
823 1
824 );
825 }
826
827 #[test]
828 fn removal_claim_is_generation_fenced_durable_and_idempotent() {
829 let directory = tempfile::tempdir().unwrap();
830 let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
831 let id = ExecutionId::new("execution-1").unwrap();
832 store
833 .reserve(managed_record(id.as_str(), "operation-1"))
834 .unwrap();
835
836 let claimed = store
837 .begin_remove(&id, ExecutionGeneration::INITIAL)
838 .unwrap()
839 .unwrap();
840 assert_eq!(
841 claimed.managed_state().unwrap(),
842 Some(ManagedExecutionState::Removing)
843 );
844 assert!(matches!(
845 claimed
846 .managed_execution
847 .as_ref()
848 .unwrap()
849 .pending_operation,
850 Some(ManagedExecutionOperation::Remove)
851 ));
852
853 let reopened = ManagedExecutionStore::new(store.path().to_path_buf());
854 assert_eq!(
855 reopened
856 .begin_remove(&id, ExecutionGeneration::INITIAL)
857 .unwrap()
858 .unwrap()
859 .managed_state()
860 .unwrap(),
861 Some(ManagedExecutionState::Removing)
862 );
863 assert!(matches!(
864 reopened.begin_remove(&id, ExecutionGeneration::new(2).unwrap()),
865 Err(ManagedExecutionStoreError::Conflict { .. })
866 ));
867
868 assert!(reopened
869 .finish_remove(&id, ExecutionGeneration::INITIAL)
870 .unwrap());
871 assert!(!reopened
872 .finish_remove(&id, ExecutionGeneration::INITIAL)
873 .unwrap());
874 assert!(reopened.get(&id).unwrap().is_none());
875 }
876
877 #[test]
878 fn pause_and_resume_completion_advance_generation_once() {
879 let directory = tempfile::tempdir().unwrap();
880 let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
881 let id = ExecutionId::new("execution-1").unwrap();
882 store
883 .reserve(managed_record(id.as_str(), "operation-1"))
884 .unwrap();
885
886 store
887 .transition(
888 &id,
889 ExecutionGeneration::INITIAL,
890 ManagedExecutionState::Created,
891 ManagedExecutionState::Starting,
892 )
893 .unwrap();
894 let running = store
895 .transition(
896 &id,
897 ExecutionGeneration::INITIAL,
898 ManagedExecutionState::Starting,
899 ManagedExecutionState::Running,
900 )
901 .unwrap();
902 assert_eq!(
903 running.managed_execution.unwrap().generation,
904 ExecutionGeneration::INITIAL
905 );
906 store
907 .transition(
908 &id,
909 ExecutionGeneration::INITIAL,
910 ManagedExecutionState::Running,
911 ManagedExecutionState::Pausing,
912 )
913 .unwrap();
914 let paused = store
915 .transition(
916 &id,
917 ExecutionGeneration::INITIAL,
918 ManagedExecutionState::Pausing,
919 ManagedExecutionState::Paused,
920 )
921 .unwrap();
922 let generation_two = ExecutionGeneration::new(2).unwrap();
923 assert_eq!(paused.managed_execution.unwrap().generation, generation_two);
924 store
925 .transition(
926 &id,
927 generation_two,
928 ManagedExecutionState::Paused,
929 ManagedExecutionState::Resuming,
930 )
931 .unwrap();
932 let resumed = store
933 .transition(
934 &id,
935 generation_two,
936 ManagedExecutionState::Resuming,
937 ManagedExecutionState::Running,
938 )
939 .unwrap();
940 assert_eq!(
941 resumed.managed_execution.unwrap().generation,
942 ExecutionGeneration::new(3).unwrap()
943 );
944 }
945
946 #[test]
947 fn snapshot_intent_is_durable_and_preserves_runtime_generation() {
948 let directory = tempfile::tempdir().unwrap();
949 let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
950 let id = ExecutionId::new("execution-1").unwrap();
951 store
952 .reserve(managed_record(id.as_str(), "operation-1"))
953 .unwrap();
954 store
955 .transition(
956 &id,
957 ExecutionGeneration::INITIAL,
958 ManagedExecutionState::Created,
959 ManagedExecutionState::Starting,
960 )
961 .unwrap();
962 store
963 .transition(
964 &id,
965 ExecutionGeneration::INITIAL,
966 ManagedExecutionState::Starting,
967 ManagedExecutionState::Running,
968 )
969 .unwrap();
970 let snapshot_id = ExecutionSnapshotId::new("snapshot-1").unwrap();
971 let claimed = store
972 .transition_with(
973 &id,
974 ExecutionGeneration::INITIAL,
975 ManagedExecutionState::Running,
976 ManagedExecutionState::Snapshotting,
977 |record| {
978 record.managed_execution.as_mut().unwrap().pending_operation =
979 Some(ManagedExecutionOperation::Snapshot {
980 snapshot_id: snapshot_id.clone(),
981 source_state: ManagedExecutionState::Running,
982 operation_id: Some(OperationId::new("snapshot-operation-1").unwrap()),
983 freezer_applied: false,
984 });
985 },
986 )
987 .unwrap();
988 assert_eq!(
989 claimed.managed_execution.as_ref().unwrap().generation,
990 ExecutionGeneration::INITIAL
991 );
992 assert!(matches!(
993 claimed
994 .managed_execution
995 .as_ref()
996 .unwrap()
997 .pending_operation
998 .as_ref(),
999 Some(ManagedExecutionOperation::Snapshot {
1000 snapshot_id,
1001 source_state: ManagedExecutionState::Running,
1002 operation_id: Some(operation_id),
1003 freezer_applied: false,
1004 }) if snapshot_id.as_str() == "snapshot-1"
1005 && operation_id.as_str() == "snapshot-operation-1"
1006 ));
1007
1008 let snapshot_operation_id = OperationId::new("snapshot-operation-1").unwrap();
1009 let marked = store
1010 .mark_snapshot_freezer_applied(
1011 &id,
1012 ExecutionGeneration::INITIAL,
1013 &snapshot_id,
1014 Some(&snapshot_operation_id),
1015 )
1016 .unwrap();
1017 assert!(matches!(
1018 marked
1019 .managed_execution
1020 .as_ref()
1021 .unwrap()
1022 .pending_operation
1023 .as_ref(),
1024 Some(ManagedExecutionOperation::Snapshot {
1025 freezer_applied: true,
1026 ..
1027 })
1028 ));
1029 let replayed = store
1030 .mark_snapshot_freezer_applied(
1031 &id,
1032 ExecutionGeneration::INITIAL,
1033 &snapshot_id,
1034 Some(&snapshot_operation_id),
1035 )
1036 .unwrap();
1037 assert_eq!(
1038 replayed.managed_execution.as_ref().unwrap().generation,
1039 marked.managed_execution.as_ref().unwrap().generation
1040 );
1041 assert!(matches!(
1042 replayed
1043 .managed_execution
1044 .as_ref()
1045 .unwrap()
1046 .pending_operation
1047 .as_ref(),
1048 Some(ManagedExecutionOperation::Snapshot {
1049 freezer_applied: true,
1050 ..
1051 })
1052 ));
1053
1054 let reopened = ManagedExecutionStore::new(store.path().to_path_buf());
1055 let persisted = reopened.get(&id).unwrap().unwrap();
1056 assert_eq!(
1057 persisted.managed_state().unwrap(),
1058 Some(ManagedExecutionState::Snapshotting)
1059 );
1060 assert!(matches!(
1061 persisted
1062 .managed_execution
1063 .as_ref()
1064 .unwrap()
1065 .pending_operation
1066 .as_ref(),
1067 Some(ManagedExecutionOperation::Snapshot {
1068 freezer_applied: true,
1069 ..
1070 })
1071 ));
1072 let completed = reopened
1073 .transition(
1074 &id,
1075 ExecutionGeneration::INITIAL,
1076 ManagedExecutionState::Snapshotting,
1077 ManagedExecutionState::Running,
1078 )
1079 .unwrap();
1080 let metadata = completed.managed_execution.unwrap();
1081 assert_eq!(metadata.generation, ExecutionGeneration::INITIAL);
1082 assert!(metadata.pending_operation.is_none());
1083 assert!(matches!(
1084 reopened.transition(
1085 &id,
1086 ExecutionGeneration::INITIAL,
1087 ManagedExecutionState::Running,
1088 ManagedExecutionState::Snapshotting,
1089 ),
1090 Err(ManagedExecutionStoreError::InvalidRecord(_))
1091 ));
1092 }
1093
1094 #[test]
1095 fn restart_advances_generation_between_durable_teardown_and_startup() {
1096 let directory = tempfile::tempdir().unwrap();
1097 let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
1098 let id = ExecutionId::new("execution-1").unwrap();
1099 store
1100 .reserve(managed_record(id.as_str(), "operation-create"))
1101 .unwrap();
1102 store
1103 .transition(
1104 &id,
1105 ExecutionGeneration::INITIAL,
1106 ManagedExecutionState::Created,
1107 ManagedExecutionState::Starting,
1108 )
1109 .unwrap();
1110 store
1111 .transition(
1112 &id,
1113 ExecutionGeneration::INITIAL,
1114 ManagedExecutionState::Starting,
1115 ManagedExecutionState::Running,
1116 )
1117 .unwrap();
1118 let restart_operation = OperationId::new("operation-restart").unwrap();
1119 let stopping = store
1120 .transition_with(
1121 &id,
1122 ExecutionGeneration::INITIAL,
1123 ManagedExecutionState::Running,
1124 ManagedExecutionState::RestartStopping,
1125 |record| {
1126 record.managed_execution.as_mut().unwrap().pending_operation =
1127 Some(ManagedExecutionOperation::Restart {
1128 operation_id: restart_operation.clone(),
1129 source_generation: ExecutionGeneration::INITIAL,
1130 source_state: ManagedExecutionState::Running,
1131 stop_timeout_secs: Some(10),
1132 });
1133 },
1134 )
1135 .unwrap();
1136 assert_eq!(
1137 stopping.managed_execution.as_ref().unwrap().generation,
1138 ExecutionGeneration::INITIAL
1139 );
1140
1141 let starting = store
1142 .transition(
1143 &id,
1144 ExecutionGeneration::INITIAL,
1145 ManagedExecutionState::RestartStopping,
1146 ManagedExecutionState::RestartStarting,
1147 )
1148 .unwrap();
1149 let generation_two = ExecutionGeneration::new(2).unwrap();
1150 assert_eq!(
1151 starting.managed_execution.as_ref().unwrap().generation,
1152 generation_two
1153 );
1154 let running = store
1155 .transition(
1156 &id,
1157 generation_two,
1158 ManagedExecutionState::RestartStarting,
1159 ManagedExecutionState::Running,
1160 )
1161 .unwrap();
1162 let metadata = running.managed_execution.unwrap();
1163 assert_eq!(metadata.generation, generation_two);
1164 assert!(metadata.pending_operation.is_none());
1165 let completed = metadata.last_restart.unwrap();
1166 assert_eq!(completed.operation_id, restart_operation);
1167 assert_eq!(completed.source_generation, ExecutionGeneration::INITIAL);
1168 assert_eq!(completed.target_generation, generation_two);
1169 assert_eq!(completed.outcome, ManagedRestartOutcome::Running);
1170 assert_eq!(completed.stop_timeout_secs, Some(10));
1171 }
1172
1173 #[test]
1174 fn stale_generation_and_invalid_edges_do_not_change_disk() {
1175 let directory = tempfile::tempdir().unwrap();
1176 let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
1177 let id = ExecutionId::new("execution-1").unwrap();
1178 store
1179 .reserve(managed_record(id.as_str(), "operation-1"))
1180 .unwrap();
1181 store
1182 .transition(
1183 &id,
1184 ExecutionGeneration::INITIAL,
1185 ManagedExecutionState::Created,
1186 ManagedExecutionState::Starting,
1187 )
1188 .unwrap();
1189 store
1190 .transition(
1191 &id,
1192 ExecutionGeneration::INITIAL,
1193 ManagedExecutionState::Starting,
1194 ManagedExecutionState::Running,
1195 )
1196 .unwrap();
1197
1198 let stale = store.transition(
1199 &id,
1200 ExecutionGeneration::new(2).unwrap(),
1201 ManagedExecutionState::Running,
1202 ManagedExecutionState::Pausing,
1203 );
1204 let invalid = store.transition(
1205 &id,
1206 ExecutionGeneration::INITIAL,
1207 ManagedExecutionState::Running,
1208 ManagedExecutionState::Paused,
1209 );
1210
1211 assert!(matches!(
1212 stale,
1213 Err(ManagedExecutionStoreError::Conflict { .. })
1214 ));
1215 assert!(matches!(
1216 invalid,
1217 Err(ManagedExecutionStoreError::InvalidTransition { .. })
1218 ));
1219 let persisted = store.get(&id).unwrap().unwrap();
1220 assert_eq!(
1221 persisted.managed_state().unwrap(),
1222 Some(ManagedExecutionState::Running)
1223 );
1224 assert_eq!(
1225 persisted.managed_execution.unwrap().generation,
1226 ExecutionGeneration::INITIAL
1227 );
1228 }
1229
1230 #[cfg(unix)]
1231 #[test]
1232 fn concurrent_claims_have_one_winner() {
1233 let directory = tempfile::tempdir().unwrap();
1234 let store = Arc::new(ManagedExecutionStore::new(
1235 directory.path().join("boxes.json"),
1236 ));
1237 let id = ExecutionId::new("execution-1").unwrap();
1238 store
1239 .reserve(managed_record(id.as_str(), "operation-1"))
1240 .unwrap();
1241 store
1242 .transition(
1243 &id,
1244 ExecutionGeneration::INITIAL,
1245 ManagedExecutionState::Created,
1246 ManagedExecutionState::Starting,
1247 )
1248 .unwrap();
1249 store
1250 .transition(
1251 &id,
1252 ExecutionGeneration::INITIAL,
1253 ManagedExecutionState::Starting,
1254 ManagedExecutionState::Running,
1255 )
1256 .unwrap();
1257 let barrier = Arc::new(Barrier::new(3));
1258 let handles: Vec<_> = (0..2)
1259 .map(|_| {
1260 let store = Arc::clone(&store);
1261 let id = id.clone();
1262 let barrier = Arc::clone(&barrier);
1263 std::thread::spawn(move || {
1264 barrier.wait();
1265 store.transition(
1266 &id,
1267 ExecutionGeneration::INITIAL,
1268 ManagedExecutionState::Running,
1269 ManagedExecutionState::Pausing,
1270 )
1271 })
1272 })
1273 .collect();
1274 barrier.wait();
1275 let results: Vec<_> = handles
1276 .into_iter()
1277 .map(|handle| handle.join().unwrap())
1278 .collect();
1279
1280 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1281 assert_eq!(
1282 results
1283 .iter()
1284 .filter(|result| matches!(result, Err(ManagedExecutionStoreError::Conflict { .. })))
1285 .count(),
1286 1
1287 );
1288 assert_eq!(
1289 store.get(&id).unwrap().unwrap().managed_state().unwrap(),
1290 Some(ManagedExecutionState::Pausing)
1291 );
1292 }
1293}