From f55b41446b5f985283089e86fd9490f7b58ac69a Mon Sep 17 00:00:00 2001 From: MasterPtato Date: Fri, 4 Sep 2026 12:17:00 -0700 Subject: [PATCH] fix(envoy-client): ack start commands immediately instead of waiting for the periodic tick --- engine/sdks/rust/envoy-client/src/commands.rs | 25 ++++++---- .../rust/envoy-client/tests/command_dedup.rs | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/engine/sdks/rust/envoy-client/src/commands.rs b/engine/sdks/rust/envoy-client/src/commands.rs index ca35743e98..5d4180e4a8 100644 --- a/engine/sdks/rust/envoy-client/src/commands.rs +++ b/engine/sdks/rust/envoy-client/src/commands.rs @@ -18,11 +18,10 @@ pub async fn handle_commands(ctx: &mut EnvoyContext, commands: Vec = commands + // Collect every actor in the raw batch before dedup, so a replayed + // (skipped) command is still re-acked instead of being replayed forever. + let batch_actors: Vec<(String, u32)> = commands .iter() - .filter(|c| matches!(c.inner, protocol::Command::CommandStopActor(_))) .map(|c| (c.checkpoint.actor_id.clone(), c.checkpoint.generation)) .collect(); @@ -91,18 +90,22 @@ pub async fn handle_commands(ctx: &mut EnvoyContext, commands: Vec = HashMap::new(); for key in actors { if let Some(&index) = ctx.processed_command_idx.get(key) { diff --git a/engine/sdks/rust/envoy-client/tests/command_dedup.rs b/engine/sdks/rust/envoy-client/tests/command_dedup.rs index b058b25862..cac7b177dd 100644 --- a/engine/sdks/rust/envoy-client/tests/command_dedup.rs +++ b/engine/sdks/rust/envoy-client/tests/command_dedup.rs @@ -140,6 +140,26 @@ fn stop_command(actor_id: &str, generation: u32, index: i64) -> protocol::Comman } } +fn start_command(actor_id: &str, generation: u32, index: i64) -> protocol::CommandWrapper { + protocol::CommandWrapper { + checkpoint: protocol::ActorCheckpoint { + actor_id: actor_id.to_string(), + generation, + index, + }, + inner: protocol::Command::CommandStartActor(protocol::CommandStartActor { + config: protocol::ActorConfig { + name: actor_id.to_string(), + key: None, + create_ts: 0, + input: None, + }, + hibernating_requests: Vec::new(), + preloaded_kv: None, + }), + } +} + fn execute_request() -> protocol::SqliteExecuteRequest { protocol::SqliteExecuteRequest { namespace_id: "test".to_string(), @@ -289,6 +309,36 @@ fn decode_ack_checkpoints(msg: WsTxMessage) -> Vec { } } +#[tokio::test] +async fn start_command_is_acked_immediately() { + let mut ctx = new_envoy_context(); + let (ws_tx, mut ws_rx) = mpsc::unbounded_channel(); + *ctx.shared.ws_tx.lock().await = Some(ws_tx); + + handle_commands(&mut ctx, vec![start_command("actor-a", 1, 1)]).await; + + // A start left unacked stays in the engine's command subspace, which is + // re-streamed on every reconnect. Waiting for the periodic tick leaves a + // window of `ACK_COMMANDS_INTERVAL_MS` in which a reconnect replays the + // start and replaces the live actor. + let checkpoints = decode_ack_checkpoints( + ws_rx + .try_recv() + .expect("start should trigger an immediate ack"), + ); + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].actor_id, "actor-a"); + assert_eq!(checkpoints[0].generation, 1); + assert_eq!(checkpoints[0].index, 1); + + // Dedup is retained so a replay can still be suppressed in-process until + // the tick clears it. + assert_eq!( + ctx.processed_command_idx.get(&("actor-a".to_string(), 1)), + Some(&1) + ); +} + #[tokio::test] async fn stop_command_is_acked_immediately() { let mut ctx = new_envoy_context();