Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/libs/api/src/controllers/sandbox/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,9 +569,11 @@ pub(super) fn sandbox_to_cached_response(
) -> SandboxResponse {
let mut response = sandbox_to_response(sandbox, None);
if let Some(observed) = observed {
response.set_state(SandboxStateInput::new(
Some(sandbox.lifecycle.clone()),
response.set_state(SandboxStateInput::observed_on(
sandbox.lifecycle.clone(),
sandbox.active_node_id,
observed.status.clone(),
observed.node_id,
));
response.spaced_version = observed.spaced_version.clone();
response.pid = observed.pid.and_then(|value| u32::try_from(value).ok());
Expand Down
97 changes: 96 additions & 1 deletion crates/libs/api/src/models/sandbox_state.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use pplx_space_common_core::types::{NodeId, SandboxLifecycle};

/// What the admin response's `lifecycle` and `status` and the ECW sandbox
/// state are rendered from: the row's stored lifecycle and the status its node
/// last observed, kept apart so each surface decides how the two combine.
Expand All @@ -11,16 +13,50 @@
pub struct SandboxStateInput {
stored_lifecycle: Option<String>,
observed_status: Option<String>,
observation: Observation,
}

/// Whether the observed status describes the row's current execution.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum Observation {
#[default]
CurrentExecution,
/// Published by a node other than the row's active node, such as the
/// node the sandbox left or the one it was lost on.
OtherNode,
}

impl SandboxStateInput {
/// `stored_lifecycle` is absent for a sandbox only a node reports;
/// `observed_status` is absent without an observed record or when the
/// record carries no status.
/// record carries no status. The observation is taken to describe the
/// row's current execution.
pub fn new(stored_lifecycle: Option<String>, observed_status: Option<String>) -> Self {
Self {
stored_lifecycle,
observed_status,
observation: Observation::CurrentExecution,
}
}

/// A row read with the status `observed_node` last published for it. The
/// observation describes the row's current execution only when that node
/// is the row's active node; the record carries no execution generation
/// to compare.
pub fn observed_on(
stored_lifecycle: String,
active_node: Option<NodeId>,
observed_status: Option<String>,
observed_node: Option<NodeId>,
) -> Self {
let observation = match (active_node, observed_node) {
(Some(active), Some(observed)) if active == observed => Observation::CurrentExecution,
_ => Observation::OtherNode,
};
Self {
stored_lifecycle: Some(stored_lifecycle),
observed_status,
observation,
}
}

Expand All @@ -45,4 +81,63 @@ impl SandboxStateInput {
.or(self.stored_lifecycle())
.map(str::to_string)
}

/// Whether the row is a delete tombstone, `deleted` or `cleanup_required`:
/// terminal, and outside `SandboxLifecycle`.
pub fn is_tombstone(&self) -> bool {
self.stored_lifecycle().is_some_and(is_tombstone)
}

/// The lifecycle a caller-facing state is rendered from. The stored
/// lifecycle decides every terminal and at-rest state; the observed
/// status refines only a live one, and only when it describes the row's
/// current execution.
pub fn rendered_lifecycle(&self) -> Option<&str> {
let stored = self.stored_lifecycle();
if stored.is_some_and(stored_lifecycle_decides) {
return stored;
}
match self.observation {
Observation::CurrentExecution => self.observed_status().or(stored),
Observation::OtherNode => stored,
}
}
}

/// Whether a stored lifecycle is one the node's observation may not override:
/// the control plane put the row there, and a node still reporting the
/// execution it had is behind it.
fn stored_lifecycle_decides(stored: &str) -> bool {
let normalized = stored.trim().to_ascii_lowercase();
match normalized.parse::<SandboxLifecycle>() {
Ok(
SandboxLifecycle::Failed
| SandboxLifecycle::Quarantined
| SandboxLifecycle::Stopped
| SandboxLifecycle::Suspended
| SandboxLifecycle::Deleting,
) => true,
Ok(
SandboxLifecycle::Created
| SandboxLifecycle::Creating
| SandboxLifecycle::Starting
| SandboxLifecycle::Started
| SandboxLifecycle::Restoring
| SandboxLifecycle::Pausing
| SandboxLifecycle::Paused
| SandboxLifecycle::Resuming
| SandboxLifecycle::Suspending
| SandboxLifecycle::Stopping
| SandboxLifecycle::Checkpointing,
) => false,
// An unknown value keeps reading the node.
Err(_) => is_tombstone(&normalized),
}
}

fn is_tombstone(stored: &str) -> bool {
matches!(
stored.trim().to_ascii_lowercase().as_str(),
"deleted" | "cleanup_required"
)
}
Loading