use std::{sync::Arc, time::Duration};
use anyhow::Result;
use chrono::{DateTime, Utc};
use tracing::{debug, info, instrument, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignalType {
SignalCancel = 0,
SignalPause = 1,
SignalResume = 2,
}
impl From<SignalType> for i32 {
fn from(s: SignalType) -> i32 {
s as i32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstanceEventType {
EventHeartbeat = 0,
EventCompleted = 2,
EventFailed = 3,
EventSuspended = 4,
EventCustom = 5,
}
impl From<InstanceEventType> for i32 {
fn from(e: InstanceEventType) -> i32 {
e as i32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstanceStatus {
StatusUnknown = 0,
StatusPending = 1,
StatusRunning = 2,
StatusSuspended = 3,
StatusCompleted = 4,
StatusFailed = 5,
StatusCancelled = 6,
}
impl From<InstanceStatus> for i32 {
fn from(s: InstanceStatus) -> i32 {
s as i32
}
}
impl InstanceStatus {
pub fn try_from_i32(value: i32) -> Option<Self> {
match value {
0 => Some(Self::StatusUnknown),
1 => Some(Self::StatusPending),
2 => Some(Self::StatusRunning),
3 => Some(Self::StatusSuspended),
4 => Some(Self::StatusCompleted),
5 => Some(Self::StatusFailed),
6 => Some(Self::StatusCancelled),
_ => None,
}
}
}
pub struct RegisterInstanceRequest {
pub instance_id: String,
pub tenant_id: String,
pub checkpoint_id: Option<String>,
}
pub struct RegisterInstanceResponse {
pub success: bool,
pub error: String,
}
pub struct CheckpointRequest {
pub instance_id: String,
pub checkpoint_id: String,
pub state: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct Signal {
pub instance_id: String,
pub signal_type: i32,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CustomSignal {
pub checkpoint_id: String,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CheckpointErrorInfo {
pub code: String,
pub message: String,
}
pub struct CheckpointResponse {
pub found: bool,
pub state: Vec<u8>,
pub pending_signal: Option<Signal>,
pub custom_signal: Option<CustomSignal>,
pub last_error: Option<CheckpointErrorInfo>,
}
pub struct GetCheckpointRequest {
pub instance_id: String,
pub checkpoint_id: String,
}
pub struct GetCheckpointResponse {
pub found: bool,
pub state: Vec<u8>,
}
pub struct SleepRequest {
pub instance_id: String,
pub duration_ms: u64,
pub checkpoint_id: String,
pub state: Vec<u8>,
}
pub struct SleepResponse {}
pub struct InstanceEvent {
pub instance_id: String,
pub event_type: i32,
pub checkpoint_id: Option<String>,
pub payload: Vec<u8>,
pub timestamp_ms: i64,
pub subtype: Option<String>,
}
impl InstanceEvent {
pub fn event_type(&self) -> InstanceEventType {
match self.event_type {
0 => InstanceEventType::EventHeartbeat,
2 => InstanceEventType::EventCompleted,
3 => InstanceEventType::EventFailed,
4 => InstanceEventType::EventSuspended,
5 => InstanceEventType::EventCustom,
_ => InstanceEventType::EventCustom,
}
}
}
pub struct InstanceEventResponse {
pub success: bool,
pub error: Option<String>,
}
pub struct GetInstanceStatusRequest {
pub instance_id: String,
}
pub struct GetInstanceStatusResponse {
pub instance_id: String,
pub status: i32,
pub checkpoint_id: Option<String>,
pub started_at_ms: i64,
pub finished_at_ms: Option<i64>,
pub output: Option<Vec<u8>>,
pub error: Option<String>,
}
pub struct PollSignalsRequest {
pub instance_id: String,
pub checkpoint_id: Option<String>,
}
pub struct PollSignalsResponse {
pub signal: Option<Signal>,
pub custom_signal: Option<CustomSignal>,
}
pub struct SignalAck {
pub instance_id: String,
pub signal_type: i32,
pub acknowledged: bool,
}
impl SignalAck {
pub fn signal_type(&self) -> SignalType {
match self.signal_type {
0 => SignalType::SignalCancel,
1 => SignalType::SignalPause,
2 => SignalType::SignalResume,
_ => SignalType::SignalCancel,
}
}
}
pub struct ErrorMetadata {
pub category: i32,
pub severity: i32,
pub retry_hint: i32,
pub retry_after_ms: Option<u64>,
pub error_code: Option<String>,
}
impl ErrorMetadata {
pub fn category(&self) -> &'static str {
match self.category {
1 => "transient",
2 => "permanent",
3 => "business",
_ => "unknown",
}
}
pub fn severity(&self) -> &'static str {
match self.severity {
0 => "info",
1 => "warning",
2 => "error",
3 => "critical",
_ => "unknown",
}
}
pub fn retry_hint(&self) -> &'static str {
match self.retry_hint {
1 => "retry_immediately",
2 => "retry_with_backoff",
3 => "retry_after",
4 => "do_not_retry",
_ => "unknown",
}
}
}
pub struct RetryAttemptEvent {
pub instance_id: String,
pub checkpoint_id: String,
pub attempt_number: u32,
pub timestamp_ms: i64,
pub error_message: Option<String>,
pub error_metadata: Option<ErrorMetadata>,
}
use crate::error::CoreError;
use crate::persistence::{EventRecord, Persistence};
pub struct InstanceHandlerState {
pub persistence: Arc<dyn Persistence>,
}
impl InstanceHandlerState {
pub fn new(persistence: Arc<dyn Persistence>) -> Self {
Self { persistence }
}
}
#[instrument(skip(state, request), fields(instance_id = %request.instance_id))]
pub async fn handle_register_instance(
state: &InstanceHandlerState,
request: RegisterInstanceRequest,
) -> Result<RegisterInstanceResponse> {
info!(
tenant_id = %request.tenant_id,
resuming_from = ?request.checkpoint_id,
"Instance registering"
);
if request.instance_id.is_empty() {
return Ok(RegisterInstanceResponse {
success: false,
error: "instance_id is required".to_string(),
});
}
if request.tenant_id.is_empty() {
return Ok(RegisterInstanceResponse {
success: false,
error: "tenant_id is required".to_string(),
});
}
if let Some(ref cp_id) = request.checkpoint_id {
let checkpoint = state
.persistence
.load_checkpoint(&request.instance_id, cp_id)
.await;
match checkpoint {
Ok(Some(_)) => {
debug!(checkpoint_id = %cp_id, "Checkpoint found for resume");
}
Ok(None) => {
return Ok(RegisterInstanceResponse {
success: false,
error: format!("Checkpoint '{}' not found", cp_id),
});
}
Err(e) => {
return Ok(RegisterInstanceResponse {
success: false,
error: format!("Failed to verify checkpoint: {}", e),
});
}
}
}
let instance_exists = state
.persistence
.get_instance(&request.instance_id)
.await
.map(|opt| opt.is_some())
.unwrap_or(false);
if !instance_exists {
info!("Instance not found, creating self-registered instance");
if let Err(e) = state
.persistence
.register_instance(&request.instance_id, &request.tenant_id)
.await
{
return Ok(RegisterInstanceResponse {
success: false,
error: format!("Failed to create instance: {}", e),
});
}
}
let started_at = Utc::now();
if let Err(e) = state
.persistence
.update_instance_status(&request.instance_id, "running", Some(started_at))
.await
{
return Ok(RegisterInstanceResponse {
success: false,
error: format!("Failed to update instance status: {}", e),
});
}
let event = EventRecord {
id: None,
instance_id: request.instance_id.clone(),
event_type: "started".to_string(),
checkpoint_id: request.checkpoint_id.clone(),
payload: None,
created_at: started_at,
subtype: None,
};
if let Err(e) = state.persistence.insert_event(&event).await {
warn!("Failed to insert started event: {}", e);
}
info!("Instance registered successfully");
Ok(RegisterInstanceResponse {
success: true,
error: String::new(),
})
}
#[instrument(skip(state, request), fields(instance_id = %request.instance_id, checkpoint_id = %request.checkpoint_id))]
pub async fn handle_checkpoint(
state: &InstanceHandlerState,
request: CheckpointRequest,
) -> Result<CheckpointResponse> {
debug!(
state_size = request.state.len(),
"Processing checkpoint request"
);
let instance = state.persistence.get_instance(&request.instance_id).await?;
match instance {
Some(inst) => {
if inst.status != "running" {
return Err(CoreError::InvalidInstanceState {
instance_id: request.instance_id.clone(),
expected: "running".to_string(),
actual: inst.status,
}
.into());
}
}
None => {
return Err(CoreError::InstanceNotFound {
instance_id: request.instance_id.clone(),
}
.into());
}
}
if let Some(existing) = state
.persistence
.load_checkpoint(&request.instance_id, &request.checkpoint_id)
.await?
{
debug!(
checkpoint_id = %request.checkpoint_id,
state_size = existing.state.len(),
"Found existing checkpoint - returning for resume"
);
let pending_signal =
get_pending_signal(state.persistence.as_ref(), &request.instance_id).await;
let custom_signal = state
.persistence
.take_pending_custom_signal(&request.instance_id, &request.checkpoint_id)
.await?
.map(|sig| CustomSignal {
checkpoint_id: request.checkpoint_id.clone(),
payload: sig.payload.unwrap_or_default(),
});
return Ok(CheckpointResponse {
found: true,
state: existing.state,
pending_signal,
custom_signal,
last_error: None, });
}
if request.state.is_empty() {
return Ok(CheckpointResponse {
found: false,
state: vec![],
pending_signal: get_pending_signal(state.persistence.as_ref(), &request.instance_id)
.await,
custom_signal: None,
last_error: None,
});
}
state
.persistence
.save_checkpoint(&request.instance_id, &request.checkpoint_id, &request.state)
.await?;
state
.persistence
.update_instance_checkpoint(&request.instance_id, &request.checkpoint_id)
.await?;
let pending_signal = get_pending_signal(state.persistence.as_ref(), &request.instance_id).await;
let custom_signal = state
.persistence
.take_pending_custom_signal(&request.instance_id, &request.checkpoint_id)
.await?
.map(|sig| CustomSignal {
checkpoint_id: request.checkpoint_id.clone(),
payload: sig.payload.unwrap_or_default(),
});
if pending_signal.is_some() || custom_signal.is_some() {
debug!(
?pending_signal,
has_custom = custom_signal.is_some(),
"Checkpoint saved with pending signal"
);
} else {
debug!("New checkpoint saved successfully");
}
Ok(CheckpointResponse {
found: false,
state: Vec::new(),
pending_signal,
custom_signal,
last_error: None,
})
}
async fn get_pending_signal(persistence: &dyn Persistence, instance_id: &str) -> Option<Signal> {
match persistence.get_pending_signal(instance_id).await {
Ok(Some(signal)) => {
let signal_type = match signal.signal_type.as_str() {
"cancel" => SignalType::SignalCancel,
"pause" => SignalType::SignalPause,
"resume" => SignalType::SignalResume,
_ => return None,
};
Some(Signal {
instance_id: instance_id.to_string(),
signal_type: signal_type.into(),
payload: signal.payload.unwrap_or_default(),
})
}
_ => None,
}
}
#[instrument(skip(state, request), fields(instance_id = %request.instance_id, checkpoint_id = %request.checkpoint_id))]
pub async fn handle_get_checkpoint(
state: &InstanceHandlerState,
request: GetCheckpointRequest,
) -> Result<GetCheckpointResponse> {
debug!("Looking up checkpoint (read-only)");
let instance = state.persistence.get_instance(&request.instance_id).await?;
if instance.is_none() {
return Err(CoreError::InstanceNotFound {
instance_id: request.instance_id.clone(),
}
.into());
}
if let Some(checkpoint) = state
.persistence
.load_checkpoint(&request.instance_id, &request.checkpoint_id)
.await?
{
debug!(
checkpoint_id = %request.checkpoint_id,
state_size = checkpoint.state.len(),
"Checkpoint found"
);
return Ok(GetCheckpointResponse {
found: true,
state: checkpoint.state,
});
}
debug!(checkpoint_id = %request.checkpoint_id, "Checkpoint not found");
Ok(GetCheckpointResponse {
found: false,
state: Vec::new(),
})
}
#[instrument(skip(state, request), fields(instance_id = %request.instance_id, checkpoint_id = %request.checkpoint_id))]
pub async fn handle_sleep(
state: &InstanceHandlerState,
request: SleepRequest,
) -> Result<SleepResponse> {
debug!(
duration_ms = request.duration_ms,
state_size = request.state.len(),
"Processing sleep request"
);
if !request.checkpoint_id.is_empty() {
state
.persistence
.save_checkpoint(&request.instance_id, &request.checkpoint_id, &request.state)
.await?;
state
.persistence
.update_instance_checkpoint(&request.instance_id, &request.checkpoint_id)
.await?;
debug!(checkpoint_id = %request.checkpoint_id, "Sleep checkpoint saved");
}
tokio::time::sleep(Duration::from_millis(request.duration_ms)).await;
Ok(SleepResponse {})
}
#[instrument(skip(state, event), fields(instance_id = %event.instance_id, event_type = ?event.event_type()))]
pub async fn handle_instance_event(
state: &InstanceHandlerState,
event: InstanceEvent,
) -> Result<InstanceEventResponse> {
debug!(
event_type = ?event.event_type,
checkpoint_id = ?event.checkpoint_id,
payload_size = event.payload.len(),
timestamp_ms = event.timestamp_ms,
"Received instance event"
);
let event_type = map_event_type(event.event_type());
if event.instance_id.is_empty() {
return Err(CoreError::ValidationError {
field: "instance_id".to_string(),
message: "instance_id is required".to_string(),
}
.into());
}
let created_at = DateTime::from_timestamp_millis(event.timestamp_ms).unwrap_or_else(Utc::now);
let event_record = EventRecord {
id: None,
instance_id: event.instance_id.clone(),
event_type: event_type.to_string(),
checkpoint_id: event.checkpoint_id.clone(),
payload: if event.payload.is_empty() {
None
} else {
Some(event.payload.clone())
},
created_at,
subtype: event.subtype.clone(),
};
state.persistence.insert_event(&event_record).await?;
match event.event_type() {
InstanceEventType::EventHeartbeat => {
debug!("Heartbeat received");
}
InstanceEventType::EventCompleted => {
let output = if event.payload.is_empty() {
None
} else {
Some(event.payload.as_slice())
};
let applied = state
.persistence
.complete_instance_if_running(
&event.instance_id,
"completed",
output,
None,
None,
None,
)
.await?;
if applied {
info!("Instance completed successfully");
} else {
warn!("Instance completion skipped (already in terminal state)");
}
}
InstanceEventType::EventFailed => {
let error = if event.payload.is_empty() {
"Unknown error"
} else {
std::str::from_utf8(&event.payload).unwrap_or("Unknown error (binary payload)")
};
let applied = state
.persistence
.complete_instance_if_running(
&event.instance_id,
"failed",
None,
Some(error),
None,
None,
)
.await?;
if applied {
warn!(error = %error, "Instance failed");
} else {
warn!(error = %error, "Instance failure event skipped (already in terminal state)");
}
}
InstanceEventType::EventSuspended => {
if let (false, Some(checkpoint_id)) = (event.payload.is_empty(), &event.checkpoint_id) {
if let Some(sleep_data) = parse_sleep_payload(&event.payload) {
state
.persistence
.save_checkpoint(&event.instance_id, checkpoint_id, &sleep_data.state)
.await?;
state
.persistence
.update_instance_checkpoint(&event.instance_id, checkpoint_id)
.await?;
if let Some(wake_at) = sleep_data.wake_at {
state
.persistence
.set_instance_sleep(&event.instance_id, wake_at)
.await?;
}
let applied = state
.persistence
.complete_instance_with_termination_if_running(
&event.instance_id,
"suspended",
Some("sleeping"),
None, None, None, None, Some(checkpoint_id),
)
.await?;
if applied {
info!(
checkpoint_id = %checkpoint_id,
wake_at = ?sleep_data.wake_at,
"Instance sleeping until scheduled wake"
);
} else {
warn!(
checkpoint_id = %checkpoint_id,
"Instance sleep event skipped (already in terminal state)"
);
}
} else {
let applied = state
.persistence
.complete_instance_if_running(
&event.instance_id,
"suspended",
None,
None,
None,
None,
)
.await?;
if applied {
info!("Instance suspended (with payload but no valid sleep data)");
} else {
warn!("Instance suspend event skipped (already in terminal state)");
}
}
} else {
let applied = state
.persistence
.complete_instance_if_running(
&event.instance_id,
"suspended",
None,
None,
None,
None,
)
.await?;
if applied {
info!("Instance suspended");
} else {
warn!("Instance suspend event skipped (already in terminal state)");
}
}
}
InstanceEventType::EventCustom => {
debug!(subtype = ?event.subtype, "Custom event received");
}
}
Ok(InstanceEventResponse {
success: true,
error: None,
})
}
#[instrument(skip(state, request), fields(instance_id = %request.instance_id))]
pub async fn handle_get_instance_status(
state: &InstanceHandlerState,
request: GetInstanceStatusRequest,
) -> Result<GetInstanceStatusResponse> {
debug!("Getting instance status");
let instance = state.persistence.get_instance(&request.instance_id).await?;
match instance {
Some(inst) => {
let status = map_status(&inst.status);
Ok(GetInstanceStatusResponse {
instance_id: request.instance_id,
status: status.into(),
checkpoint_id: inst.checkpoint_id,
started_at_ms: inst.started_at.map(|t| t.timestamp_millis()).unwrap_or(0),
finished_at_ms: inst.finished_at.map(|t| t.timestamp_millis()),
output: inst.output,
error: inst.error,
})
}
None => Ok(GetInstanceStatusResponse {
instance_id: request.instance_id,
status: InstanceStatus::StatusUnknown.into(),
checkpoint_id: None,
started_at_ms: 0,
finished_at_ms: None,
output: None,
error: Some("Instance not found".to_string()),
}),
}
}
#[instrument(skip(state, request), fields(instance_id = %request.instance_id))]
pub async fn handle_poll_signals(
state: &InstanceHandlerState,
request: PollSignalsRequest,
) -> Result<PollSignalsResponse> {
debug!("Instance polling for signals");
let pending = state
.persistence
.get_pending_signal(&request.instance_id)
.await?;
let custom = if let Some(checkpoint_id) = request.checkpoint_id.as_deref() {
state
.persistence
.take_pending_custom_signal(&request.instance_id, checkpoint_id)
.await?
} else {
None
};
let signal = pending.map(|sig| {
let signal_type = match sig.signal_type.as_str() {
"cancel" => SignalType::SignalCancel,
"pause" => SignalType::SignalPause,
"resume" => SignalType::SignalResume,
_ => {
warn!(signal_type = %sig.signal_type, "Unknown signal type");
SignalType::SignalCancel
}
};
Signal {
instance_id: request.instance_id.clone(),
signal_type: signal_type.into(),
payload: sig.payload.unwrap_or_default(),
}
});
let custom_signal = custom.map(|sig| CustomSignal {
checkpoint_id: sig.checkpoint_id,
payload: sig.payload.unwrap_or_default(),
});
if signal.is_some() || custom_signal.is_some() {
debug!(
has_global = signal.is_some(),
has_custom = custom_signal.is_some(),
"Returning pending signals"
);
}
Ok(PollSignalsResponse {
signal,
custom_signal,
})
}
#[instrument(skip(state, ack), fields(instance_id = %ack.instance_id))]
pub async fn handle_signal_ack(state: &InstanceHandlerState, ack: SignalAck) -> Result<()> {
debug!(
signal_type = ?ack.signal_type,
acknowledged = ack.acknowledged,
"Received signal acknowledgement"
);
if ack.acknowledged {
state
.persistence
.acknowledge_signal(&ack.instance_id)
.await?;
match ack.signal_type() {
SignalType::SignalCancel => {
state
.persistence
.complete_instance_extended(
&ack.instance_id,
"cancelled",
None, None, None, None, )
.await?;
info!("Instance cancelled");
}
SignalType::SignalPause => {
state
.persistence
.update_instance_status(&ack.instance_id, "suspended", None)
.await?;
info!("Instance paused/suspended");
}
SignalType::SignalResume => {
debug!("Resume signal acknowledged");
}
}
} else {
warn!("Signal was not acknowledged by instance");
}
Ok(())
}
#[instrument(skip(state, event), fields(
instance_id = %event.instance_id,
checkpoint_id = %event.checkpoint_id,
attempt = event.attempt_number,
error_message = ?event.error_message,
))]
pub async fn handle_retry_attempt(
state: &InstanceHandlerState,
event: RetryAttemptEvent,
) -> Result<()> {
debug!(timestamp_ms = event.timestamp_ms, "Recording retry attempt");
state
.persistence
.save_retry_attempt(
&event.instance_id,
&event.checkpoint_id,
event.attempt_number as i32,
event.error_message.as_deref(),
)
.await?;
if let Some(ref meta) = event.error_metadata {
info!(
error_category = ?meta.category(),
error_severity = ?meta.severity(),
retry_hint = ?meta.retry_hint(),
error_code = ?meta.error_code,
retry_after_ms = ?meta.retry_after_ms,
"Retry attempt with error metadata"
);
}
debug!("Retry attempt recorded");
Ok(())
}
pub fn map_event_type(event_type: InstanceEventType) -> &'static str {
match event_type {
InstanceEventType::EventHeartbeat => "heartbeat",
InstanceEventType::EventCompleted => "completed",
InstanceEventType::EventFailed => "failed",
InstanceEventType::EventSuspended => "suspended",
InstanceEventType::EventCustom => "custom",
}
}
pub fn map_status(status: &str) -> InstanceStatus {
match status {
"pending" => InstanceStatus::StatusPending,
"running" => InstanceStatus::StatusRunning,
"suspended" => InstanceStatus::StatusSuspended,
"completed" => InstanceStatus::StatusCompleted,
"failed" => InstanceStatus::StatusFailed,
"cancelled" => InstanceStatus::StatusCancelled,
_ => InstanceStatus::StatusUnknown,
}
}
pub fn map_signal_type(signal_type: SignalType) -> &'static str {
match signal_type {
SignalType::SignalCancel => "cancel",
SignalType::SignalPause => "pause",
SignalType::SignalResume => "resume",
}
}
struct SleepPayload {
wake_at: Option<DateTime<Utc>>,
state: Vec<u8>,
}
fn parse_sleep_payload(payload: &[u8]) -> Option<SleepPayload> {
use base64::Engine;
let json: serde_json::Value = serde_json::from_slice(payload).ok()?;
let wake_at_ms = json.get("wake_at_ms")?.as_i64()?;
let wake_at = DateTime::from_timestamp_millis(wake_at_ms);
let state_b64 = json.get("state")?.as_str()?;
let state = base64::engine::general_purpose::STANDARD
.decode(state_b64)
.ok()?;
Some(SleepPayload { wake_at, state })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::persistence::{
CheckpointRecord, CustomSignalRecord, InstanceRecord, ListEventsFilter,
ListStepSummariesFilter, SignalRecord, StepSummaryRecord,
};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockPersistence {
instances: Mutex<HashMap<String, InstanceRecord>>,
checkpoints: Mutex<HashMap<(String, String), CheckpointRecord>>,
signals: Mutex<HashMap<String, SignalRecord>>,
events: Mutex<Vec<EventRecord>>,
custom_signals: Mutex<HashMap<(String, String), CustomSignalRecord>>,
fail_register: Mutex<bool>,
fail_status_update: Mutex<bool>,
}
impl MockPersistence {
fn new() -> Self {
Self {
instances: Mutex::new(HashMap::new()),
checkpoints: Mutex::new(HashMap::new()),
signals: Mutex::new(HashMap::new()),
events: Mutex::new(Vec::new()),
custom_signals: Mutex::new(HashMap::new()),
fail_register: Mutex::new(false),
fail_status_update: Mutex::new(false),
}
}
fn with_instance(self, instance: InstanceRecord) -> Self {
self.instances
.lock()
.unwrap()
.insert(instance.instance_id.clone(), instance);
self
}
fn with_checkpoint(self, checkpoint: CheckpointRecord) -> Self {
self.checkpoints.lock().unwrap().insert(
(
checkpoint.instance_id.clone(),
checkpoint.checkpoint_id.clone(),
),
checkpoint,
);
self
}
fn with_signal(self, signal: SignalRecord) -> Self {
self.signals
.lock()
.unwrap()
.insert(signal.instance_id.clone(), signal);
self
}
fn with_custom_signal(self, signal: CustomSignalRecord) -> Self {
self.custom_signals.lock().unwrap().insert(
(signal.instance_id.clone(), signal.checkpoint_id.clone()),
signal,
);
self
}
#[allow(dead_code)]
fn set_fail_register(&self) {
*self.fail_register.lock().unwrap() = true;
}
#[allow(dead_code)]
fn set_fail_status_update(&self) {
*self.fail_status_update.lock().unwrap() = true;
}
fn get_events(&self) -> Vec<EventRecord> {
self.events.lock().unwrap().clone()
}
}
fn make_instance(instance_id: &str, tenant_id: &str, status: &str) -> InstanceRecord {
InstanceRecord {
instance_id: instance_id.to_string(),
tenant_id: tenant_id.to_string(),
definition_version: 1,
status: status.to_string(),
checkpoint_id: None,
attempt: 1,
max_attempts: 3,
created_at: Utc::now(),
started_at: None,
finished_at: None,
input: None,
output: None,
error: None,
sleep_until: None,
termination_reason: None,
exit_code: None,
}
}
fn make_checkpoint(instance_id: &str, checkpoint_id: &str, state: &[u8]) -> CheckpointRecord {
CheckpointRecord {
id: 1,
instance_id: instance_id.to_string(),
checkpoint_id: checkpoint_id.to_string(),
state: state.to_vec(),
created_at: Utc::now(),
is_compensatable: false,
compensation_step_id: None,
compensation_data: None,
compensation_state: None,
compensation_order: 0,
}
}
fn make_signal(instance_id: &str, signal_type: &str) -> SignalRecord {
SignalRecord {
instance_id: instance_id.to_string(),
signal_type: signal_type.to_string(),
payload: None,
created_at: Utc::now(),
acknowledged_at: None,
}
}
#[async_trait]
impl Persistence for MockPersistence {
async fn register_instance(
&self,
instance_id: &str,
tenant_id: &str,
) -> std::result::Result<(), CoreError> {
if *self.fail_register.lock().unwrap() {
return Err(CoreError::DatabaseError {
operation: "register_instance".to_string(),
details: "Mock register failure".to_string(),
});
}
let instance = make_instance(instance_id, tenant_id, "pending");
self.instances
.lock()
.unwrap()
.insert(instance_id.to_string(), instance);
Ok(())
}
async fn get_instance(
&self,
instance_id: &str,
) -> std::result::Result<Option<InstanceRecord>, CoreError> {
Ok(self.instances.lock().unwrap().get(instance_id).cloned())
}
async fn update_instance_status(
&self,
instance_id: &str,
status: &str,
started_at: Option<DateTime<Utc>>,
) -> std::result::Result<(), CoreError> {
if *self.fail_status_update.lock().unwrap() {
return Err(CoreError::DatabaseError {
operation: "update_instance_status".to_string(),
details: "Mock status update failure".to_string(),
});
}
if let Some(inst) = self.instances.lock().unwrap().get_mut(instance_id) {
inst.status = status.to_string();
inst.started_at = started_at;
}
Ok(())
}
async fn update_instance_checkpoint(
&self,
instance_id: &str,
checkpoint_id: &str,
) -> std::result::Result<(), CoreError> {
if let Some(inst) = self.instances.lock().unwrap().get_mut(instance_id) {
inst.checkpoint_id = Some(checkpoint_id.to_string());
}
Ok(())
}
async fn complete_instance(
&self,
instance_id: &str,
output: Option<&[u8]>,
error: Option<&str>,
) -> std::result::Result<(), CoreError> {
if let Some(inst) = self.instances.lock().unwrap().get_mut(instance_id) {
inst.status = if error.is_some() {
"failed".to_string()
} else {
"completed".to_string()
};
inst.output = output.map(|o| o.to_vec());
inst.error = error.map(|e| e.to_string());
inst.finished_at = Some(Utc::now());
}
Ok(())
}
async fn complete_instance_if_running(
&self,
instance_id: &str,
status: &str,
output: Option<&[u8]>,
error: Option<&str>,
_stderr: Option<&str>,
checkpoint_id: Option<&str>,
) -> std::result::Result<bool, CoreError> {
let mut instances = self.instances.lock().unwrap();
if let Some(inst) = instances.get_mut(instance_id)
&& inst.status == "running"
{
inst.status = status.to_string();
inst.output = output.map(|o| o.to_vec());
inst.error = error.map(|e| e.to_string());
inst.checkpoint_id = checkpoint_id.map(|s| s.to_string());
if status == "completed"
|| status == "failed"
|| status == "cancelled"
|| status == "suspended"
{
inst.finished_at = Some(Utc::now());
}
return Ok(true);
}
Ok(false)
}
async fn complete_instance_with_termination(
&self,
instance_id: &str,
status: &str,
termination_reason: Option<&str>,
exit_code: Option<i32>,
output: Option<&[u8]>,
error: Option<&str>,
_stderr: Option<&str>,
checkpoint_id: Option<&str>,
) -> std::result::Result<(), CoreError> {
if let Some(inst) = self.instances.lock().unwrap().get_mut(instance_id) {
inst.status = status.to_string();
inst.termination_reason = termination_reason.map(|s| s.to_string());
inst.exit_code = exit_code;
inst.output = output.map(|o| o.to_vec());
inst.error = error.map(|e| e.to_string());
inst.checkpoint_id = checkpoint_id.map(|s| s.to_string());
if status == "completed" || status == "failed" || status == "cancelled" {
inst.finished_at = Some(Utc::now());
}
}
Ok(())
}
async fn complete_instance_with_termination_if_running(
&self,
instance_id: &str,
status: &str,
termination_reason: Option<&str>,
exit_code: Option<i32>,
output: Option<&[u8]>,
error: Option<&str>,
_stderr: Option<&str>,
checkpoint_id: Option<&str>,
) -> std::result::Result<bool, CoreError> {
let mut instances = self.instances.lock().unwrap();
if let Some(inst) = instances.get_mut(instance_id)
&& inst.status == "running"
{
inst.status = status.to_string();
inst.termination_reason = termination_reason.map(|s| s.to_string());
inst.exit_code = exit_code;
inst.output = output.map(|o| o.to_vec());
inst.error = error.map(|e| e.to_string());
inst.checkpoint_id = checkpoint_id.map(|s| s.to_string());
if status == "completed" || status == "failed" || status == "cancelled" {
inst.finished_at = Some(Utc::now());
}
return Ok(true);
}
Ok(false)
}
async fn save_checkpoint(
&self,
instance_id: &str,
checkpoint_id: &str,
state: &[u8],
) -> std::result::Result<(), CoreError> {
let cp = make_checkpoint(instance_id, checkpoint_id, state);
self.checkpoints
.lock()
.unwrap()
.insert((instance_id.to_string(), checkpoint_id.to_string()), cp);
Ok(())
}
async fn load_checkpoint(
&self,
instance_id: &str,
checkpoint_id: &str,
) -> std::result::Result<Option<CheckpointRecord>, CoreError> {
Ok(self
.checkpoints
.lock()
.unwrap()
.get(&(instance_id.to_string(), checkpoint_id.to_string()))
.cloned())
}
async fn list_checkpoints(
&self,
_instance_id: &str,
_checkpoint_id: Option<&str>,
_limit: i64,
_offset: i64,
_created_after: Option<DateTime<Utc>>,
_created_before: Option<DateTime<Utc>>,
) -> std::result::Result<Vec<CheckpointRecord>, CoreError> {
Ok(Vec::new())
}
async fn count_checkpoints(
&self,
_instance_id: &str,
_checkpoint_id: Option<&str>,
_created_after: Option<DateTime<Utc>>,
_created_before: Option<DateTime<Utc>>,
) -> std::result::Result<i64, CoreError> {
Ok(0)
}
async fn insert_event(&self, event: &EventRecord) -> std::result::Result<(), CoreError> {
self.events.lock().unwrap().push(event.clone());
Ok(())
}
async fn insert_signal(
&self,
instance_id: &str,
signal_type: &str,
_payload: &[u8],
) -> std::result::Result<(), CoreError> {
let signal = make_signal(instance_id, signal_type);
self.signals
.lock()
.unwrap()
.insert(instance_id.to_string(), signal);
Ok(())
}
async fn get_pending_signal(
&self,
instance_id: &str,
) -> std::result::Result<Option<SignalRecord>, CoreError> {
Ok(self.signals.lock().unwrap().get(instance_id).cloned())
}
async fn acknowledge_signal(
&self,
instance_id: &str,
) -> std::result::Result<(), CoreError> {
self.signals.lock().unwrap().remove(instance_id);
Ok(())
}
async fn insert_custom_signal(
&self,
_instance_id: &str,
_checkpoint_id: &str,
_payload: &[u8],
) -> std::result::Result<(), CoreError> {
Ok(())
}
async fn take_pending_custom_signal(
&self,
instance_id: &str,
checkpoint_id: &str,
) -> std::result::Result<Option<CustomSignalRecord>, CoreError> {
Ok(self
.custom_signals
.lock()
.unwrap()
.remove(&(instance_id.to_string(), checkpoint_id.to_string())))
}
async fn save_retry_attempt(
&self,
_instance_id: &str,
_checkpoint_id: &str,
_attempt: i32,
_error_message: Option<&str>,
) -> std::result::Result<(), CoreError> {
Ok(())
}
async fn list_instances(
&self,
_tenant_id: Option<&str>,
_status: Option<&str>,
_limit: i64,
_offset: i64,
) -> std::result::Result<Vec<InstanceRecord>, CoreError> {
Ok(Vec::new())
}
async fn health_check_db(&self) -> std::result::Result<bool, CoreError> {
Ok(true)
}
async fn count_active_instances(&self) -> std::result::Result<i64, CoreError> {
Ok(0)
}
async fn set_instance_sleep(
&self,
instance_id: &str,
sleep_until: DateTime<Utc>,
) -> std::result::Result<(), CoreError> {
if let Some(inst) = self.instances.lock().unwrap().get_mut(instance_id) {
inst.sleep_until = Some(sleep_until);
}
Ok(())
}
async fn clear_instance_sleep(
&self,
_instance_id: &str,
) -> std::result::Result<(), CoreError> {
Ok(())
}
async fn get_sleeping_instances_due(
&self,
_limit: i64,
) -> std::result::Result<Vec<InstanceRecord>, CoreError> {
Ok(Vec::new())
}
async fn list_events(
&self,
_instance_id: &str,
_filter: &ListEventsFilter,
_limit: i64,
_offset: i64,
) -> std::result::Result<Vec<EventRecord>, CoreError> {
Ok(Vec::new())
}
async fn count_events(
&self,
_instance_id: &str,
_filter: &ListEventsFilter,
) -> std::result::Result<i64, CoreError> {
Ok(0)
}
async fn list_step_summaries(
&self,
_instance_id: &str,
_filter: &ListStepSummariesFilter,
_limit: i64,
_offset: i64,
) -> std::result::Result<Vec<StepSummaryRecord>, CoreError> {
Ok(Vec::new())
}
async fn count_step_summaries(
&self,
_instance_id: &str,
_filter: &ListStepSummariesFilter,
) -> std::result::Result<i64, CoreError> {
Ok(0)
}
}
#[test]
fn test_event_type_mapping() {
assert_eq!(
map_event_type(InstanceEventType::EventHeartbeat),
"heartbeat"
);
assert_eq!(
map_event_type(InstanceEventType::EventCompleted),
"completed"
);
assert_eq!(map_event_type(InstanceEventType::EventFailed), "failed");
assert_eq!(
map_event_type(InstanceEventType::EventSuspended),
"suspended"
);
assert_eq!(map_event_type(InstanceEventType::EventCustom), "custom");
}
#[test]
fn test_status_mapping_all_variants() {
assert_eq!(map_status("pending"), InstanceStatus::StatusPending);
assert_eq!(map_status("running"), InstanceStatus::StatusRunning);
assert_eq!(map_status("suspended"), InstanceStatus::StatusSuspended);
assert_eq!(map_status("completed"), InstanceStatus::StatusCompleted);
assert_eq!(map_status("failed"), InstanceStatus::StatusFailed);
assert_eq!(map_status("cancelled"), InstanceStatus::StatusCancelled);
assert_eq!(map_status("invalid"), InstanceStatus::StatusUnknown);
assert_eq!(map_status(""), InstanceStatus::StatusUnknown);
}
#[test]
fn test_signal_type_mapping() {
assert_eq!(map_signal_type(SignalType::SignalCancel), "cancel");
assert_eq!(map_signal_type(SignalType::SignalPause), "pause");
assert_eq!(map_signal_type(SignalType::SignalResume), "resume");
}
#[test]
fn test_instance_handler_state_new() {
let persistence = Arc::new(MockPersistence::new());
let state = InstanceHandlerState::new(persistence);
let _ = &state.persistence;
}
#[tokio::test]
async fn test_register_empty_instance_id() {
let persistence = Arc::new(MockPersistence::new());
let state = InstanceHandlerState::new(persistence);
let request = RegisterInstanceRequest {
instance_id: "".to_string(),
tenant_id: "tenant-1".to_string(),
checkpoint_id: None,
};
let result = handle_register_instance(&state, request).await.unwrap();
assert!(!result.success);
assert!(result.error.contains("instance_id is required"));
}
#[tokio::test]
async fn test_register_empty_tenant_id() {
let persistence = Arc::new(MockPersistence::new());
let state = InstanceHandlerState::new(persistence);
let request = RegisterInstanceRequest {
instance_id: "inst-1".to_string(),
tenant_id: "".to_string(),
checkpoint_id: None,
};
let result = handle_register_instance(&state, request).await.unwrap();
assert!(!result.success);
assert!(result.error.contains("tenant_id is required"));
}
#[tokio::test]
async fn test_register_self_registration() {
let persistence = Arc::new(MockPersistence::new());
let state = InstanceHandlerState::new(persistence);
let request = RegisterInstanceRequest {
instance_id: "inst-new".to_string(),
tenant_id: "tenant-1".to_string(),
checkpoint_id: None,
};
let result = handle_register_instance(&state, request).await.unwrap();
assert!(result.success);
assert!(result.error.is_empty());
}
#[tokio::test]
async fn test_register_existing_instance() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "pending")),
);
let state = InstanceHandlerState::new(persistence);
let request = RegisterInstanceRequest {
instance_id: "inst-1".to_string(),
tenant_id: "tenant-1".to_string(),
checkpoint_id: None,
};
let result = handle_register_instance(&state, request).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_register_with_valid_checkpoint() {
let persistence = Arc::new(
MockPersistence::new()
.with_instance(make_instance("inst-1", "tenant-1", "pending"))
.with_checkpoint(make_checkpoint("inst-1", "cp-1", b"state")),
);
let state = InstanceHandlerState::new(persistence);
let request = RegisterInstanceRequest {
instance_id: "inst-1".to_string(),
tenant_id: "tenant-1".to_string(),
checkpoint_id: Some("cp-1".to_string()),
};
let result = handle_register_instance(&state, request).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_register_with_invalid_checkpoint() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "pending")),
);
let state = InstanceHandlerState::new(persistence);
let request = RegisterInstanceRequest {
instance_id: "inst-1".to_string(),
tenant_id: "tenant-1".to_string(),
checkpoint_id: Some("nonexistent".to_string()),
};
let result = handle_register_instance(&state, request).await.unwrap();
assert!(!result.success);
assert!(result.error.contains("not found"));
}
#[tokio::test]
async fn test_register_creates_started_event() {
let mock = MockPersistence::new();
let persistence = Arc::new(mock);
let state = InstanceHandlerState::new(persistence.clone());
let request = RegisterInstanceRequest {
instance_id: "inst-1".to_string(),
tenant_id: "tenant-1".to_string(),
checkpoint_id: None,
};
let result = handle_register_instance(&state, request).await.unwrap();
assert!(result.success);
let events = persistence.get_events();
assert!(!events.is_empty());
assert_eq!(events[0].event_type, "started");
assert_eq!(events[0].instance_id, "inst-1");
}
#[tokio::test]
async fn test_checkpoint_instance_not_found() {
let persistence = Arc::new(MockPersistence::new());
let state = InstanceHandlerState::new(persistence);
let request = CheckpointRequest {
instance_id: "nonexistent".to_string(),
checkpoint_id: "cp-1".to_string(),
state: b"test state".to_vec(),
};
let result = handle_checkpoint(&state, request).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_checkpoint_instance_not_running() {
let persistence = Arc::new(MockPersistence::new().with_instance(make_instance(
"inst-1",
"tenant-1",
"completed",
)));
let state = InstanceHandlerState::new(persistence);
let request = CheckpointRequest {
instance_id: "inst-1".to_string(),
checkpoint_id: "cp-1".to_string(),
state: b"test state".to_vec(),
};
let result = handle_checkpoint(&state, request).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_checkpoint_new_saves_state() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence);
let request = CheckpointRequest {
instance_id: "inst-1".to_string(),
checkpoint_id: "cp-1".to_string(),
state: b"test state".to_vec(),
};
let result = handle_checkpoint(&state, request).await.unwrap();
assert!(!result.found); }
#[tokio::test]
async fn test_checkpoint_existing_returns_state() {
let persistence = Arc::new(
MockPersistence::new()
.with_instance(make_instance("inst-1", "tenant-1", "running"))
.with_checkpoint(make_checkpoint("inst-1", "cp-1", b"existing state")),
);
let state = InstanceHandlerState::new(persistence);
let request = CheckpointRequest {
instance_id: "inst-1".to_string(),
checkpoint_id: "cp-1".to_string(),
state: b"new state".to_vec(), };
let result = handle_checkpoint(&state, request).await.unwrap();
assert!(result.found);
assert_eq!(result.state, b"existing state");
}
#[tokio::test]
async fn test_checkpoint_returns_pending_signal() {
let persistence = Arc::new(
MockPersistence::new()
.with_instance(make_instance("inst-1", "tenant-1", "running"))
.with_signal(make_signal("inst-1", "cancel")),
);
let state = InstanceHandlerState::new(persistence);
let request = CheckpointRequest {
instance_id: "inst-1".to_string(),
checkpoint_id: "cp-1".to_string(),
state: b"test state".to_vec(),
};
let result = handle_checkpoint(&state, request).await.unwrap();
assert!(result.pending_signal.is_some());
let signal = result.pending_signal.unwrap();
assert_eq!(signal.signal_type, SignalType::SignalCancel as i32);
}
#[tokio::test]
async fn test_checkpoint_returns_custom_signal() {
let custom_signal = CustomSignalRecord {
instance_id: "inst-1".to_string(),
checkpoint_id: "cp-1".to_string(),
payload: Some(b"custom payload".to_vec()),
created_at: Utc::now(),
};
let persistence = Arc::new(
MockPersistence::new()
.with_instance(make_instance("inst-1", "tenant-1", "running"))
.with_custom_signal(custom_signal),
);
let state = InstanceHandlerState::new(persistence);
let request = CheckpointRequest {
instance_id: "inst-1".to_string(),
checkpoint_id: "cp-1".to_string(),
state: b"test state".to_vec(),
};
let result = handle_checkpoint(&state, request).await.unwrap();
assert!(result.custom_signal.is_some());
let cs = result.custom_signal.unwrap();
assert_eq!(cs.checkpoint_id, "cp-1");
assert_eq!(cs.payload, b"custom payload");
}
#[tokio::test]
async fn test_get_status_not_found() {
let persistence = Arc::new(MockPersistence::new());
let state = InstanceHandlerState::new(persistence);
let request = GetInstanceStatusRequest {
instance_id: "nonexistent".to_string(),
};
let result = handle_get_instance_status(&state, request).await.unwrap();
assert_eq!(result.status, InstanceStatus::StatusUnknown as i32);
}
#[tokio::test]
async fn test_get_status_found() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence);
let request = GetInstanceStatusRequest {
instance_id: "inst-1".to_string(),
};
let result = handle_get_instance_status(&state, request).await.unwrap();
assert_eq!(result.status, InstanceStatus::StatusRunning as i32);
}
#[tokio::test]
async fn test_poll_signals_no_signal() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence);
let request = PollSignalsRequest {
instance_id: "inst-1".to_string(),
checkpoint_id: None,
};
let result = handle_poll_signals(&state, request).await.unwrap();
assert!(result.signal.is_none());
}
#[tokio::test]
async fn test_poll_signals_with_pending_signal() {
let persistence = Arc::new(
MockPersistence::new()
.with_instance(make_instance("inst-1", "tenant-1", "running"))
.with_signal(make_signal("inst-1", "pause")),
);
let state = InstanceHandlerState::new(persistence);
let request = PollSignalsRequest {
instance_id: "inst-1".to_string(),
checkpoint_id: None,
};
let result = handle_poll_signals(&state, request).await.unwrap();
assert!(result.signal.is_some());
let signal = result.signal.unwrap();
assert_eq!(signal.signal_type, SignalType::SignalPause as i32);
}
#[tokio::test]
async fn test_signal_ack_success() {
let persistence = Arc::new(
MockPersistence::new()
.with_instance(make_instance("inst-1", "tenant-1", "running"))
.with_signal(make_signal("inst-1", "cancel")),
);
let state = InstanceHandlerState::new(persistence.clone());
let request = SignalAck {
instance_id: "inst-1".to_string(),
signal_type: SignalType::SignalCancel as i32,
acknowledged: true,
};
handle_signal_ack(&state, request).await.unwrap();
assert!(
persistence
.get_pending_signal("inst-1")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn test_handle_event_heartbeat() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence.clone());
let event = InstanceEvent {
instance_id: "inst-1".to_string(),
event_type: InstanceEventType::EventHeartbeat as i32,
checkpoint_id: None,
payload: Vec::new(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
subtype: None,
};
let result = handle_instance_event(&state, event).await.unwrap();
assert!(result.success);
let events = persistence.get_events();
assert!(!events.is_empty());
assert_eq!(events[0].event_type, "heartbeat");
}
#[tokio::test]
async fn test_handle_event_completed() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence.clone());
let event = InstanceEvent {
instance_id: "inst-1".to_string(),
event_type: InstanceEventType::EventCompleted as i32,
checkpoint_id: None,
payload: b"result".to_vec(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
subtype: None,
};
let result = handle_instance_event(&state, event).await.unwrap();
assert!(result.success);
let inst = persistence.get_instance("inst-1").await.unwrap().unwrap();
assert_eq!(inst.status, "completed");
}
#[tokio::test]
async fn test_handle_event_failed() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence.clone());
let event = InstanceEvent {
instance_id: "inst-1".to_string(),
event_type: InstanceEventType::EventFailed as i32,
checkpoint_id: None,
payload: b"error message".to_vec(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
subtype: None,
};
let result = handle_instance_event(&state, event).await.unwrap();
assert!(result.success);
let inst = persistence.get_instance("inst-1").await.unwrap().unwrap();
assert_eq!(inst.status, "failed");
}
#[tokio::test]
async fn test_handle_event_suspended() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence.clone());
let event = InstanceEvent {
instance_id: "inst-1".to_string(),
event_type: InstanceEventType::EventSuspended as i32,
checkpoint_id: None,
payload: Vec::new(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
subtype: None,
};
let result = handle_instance_event(&state, event).await.unwrap();
assert!(result.success);
let inst = persistence.get_instance("inst-1").await.unwrap().unwrap();
assert_eq!(inst.status, "suspended");
}
#[tokio::test]
async fn test_handle_event_custom() {
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence.clone());
let event = InstanceEvent {
instance_id: "inst-1".to_string(),
event_type: InstanceEventType::EventCustom as i32,
checkpoint_id: None,
payload: b"custom data".to_vec(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
subtype: Some("my_custom_type".to_string()),
};
let result = handle_instance_event(&state, event).await.unwrap();
assert!(result.success);
let events = persistence.get_events();
assert!(!events.is_empty());
assert_eq!(events[0].event_type, "custom");
assert_eq!(events[0].subtype.as_deref(), Some("my_custom_type"));
}
#[tokio::test]
async fn test_handle_event_suspended_with_sleep() {
use base64::Engine;
let persistence = Arc::new(
MockPersistence::new().with_instance(make_instance("inst-1", "tenant-1", "running")),
);
let state = InstanceHandlerState::new(persistence.clone());
let wake_at = chrono::Utc::now() + chrono::Duration::hours(1);
let checkpoint_state = b"test checkpoint state";
let payload = serde_json::json!({
"wake_at_ms": wake_at.timestamp_millis(),
"state": base64::engine::general_purpose::STANDARD.encode(checkpoint_state),
});
let event = InstanceEvent {
instance_id: "inst-1".to_string(),
event_type: InstanceEventType::EventSuspended as i32,
checkpoint_id: Some("sleep-cp-1".to_string()),
payload: payload.to_string().into_bytes(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
subtype: None,
};
let result = handle_instance_event(&state, event).await.unwrap();
assert!(result.success);
let inst = persistence.get_instance("inst-1").await.unwrap().unwrap();
assert_eq!(inst.status, "suspended");
assert_eq!(inst.termination_reason.as_deref(), Some("sleeping"));
assert_eq!(inst.checkpoint_id.as_deref(), Some("sleep-cp-1"));
assert!(inst.sleep_until.is_some());
let cp = persistence
.load_checkpoint("inst-1", "sleep-cp-1")
.await
.unwrap()
.unwrap();
assert_eq!(cp.state, checkpoint_state);
}
#[test]
fn test_parse_sleep_payload_valid() {
use base64::Engine;
let state = b"test state";
let wake_at_ms = 1750000000000i64; let payload = serde_json::json!({
"wake_at_ms": wake_at_ms,
"state": base64::engine::general_purpose::STANDARD.encode(state),
});
let result = parse_sleep_payload(&payload.to_string().into_bytes());
assert!(result.is_some());
let sleep_data = result.unwrap();
assert_eq!(sleep_data.state, state);
assert!(sleep_data.wake_at.is_some());
assert_eq!(sleep_data.wake_at.unwrap().timestamp_millis(), wake_at_ms);
}
#[test]
fn test_parse_sleep_payload_invalid_json() {
let result = parse_sleep_payload(b"not json");
assert!(result.is_none());
}
#[test]
fn test_parse_sleep_payload_missing_fields() {
let payload = serde_json::json!({
"wake_at_ms": 1750000000000i64,
});
let result = parse_sleep_payload(&payload.to_string().into_bytes());
assert!(result.is_none());
}
}