Skip to content

Commit bb0f509

Browse files
committed
fix(envoy-client): signal lost actor generations synchronously
1 parent d389d55 commit bb0f509

10 files changed

Lines changed: 402 additions & 32 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎engine/sdks/rust/envoy-client/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ serde.workspace = true
2929
serde_bare.workspace = true
3030
serde_json.workspace = true
3131
tokio-tungstenite = { workspace = true, optional = true }
32+
tokio-util.workspace = true
3233
tracing.workspace = true
3334
urlencoding.workspace = true
3435
vbare.workspace = true

‎engine/sdks/rust/envoy-client/src/actor.rs‎

Lines changed: 199 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use rivet_envoy_protocol as protocol;
88
use tokio::sync::oneshot::error::TryRecvError;
99
use tokio::sync::{mpsc, oneshot};
1010
use tokio::task::JoinSet;
11+
use tokio_util::sync::CancellationToken;
1112
use tracing::Instrument;
1213

1314
use crate::connection::ws_send;
@@ -115,6 +116,8 @@ struct ActorContext {
115116
ws_entries: BufferMap<WsEntry>,
116117
hibernating_requests: Vec<protocol::HibernatingRequest>,
117118
active_http_request_count: Arc<AsyncCounter>,
119+
/// Cancelled when this generation is declared lost, by the engine or by the envoy itself.
120+
lost: CancellationToken,
118121
}
119122

120123
struct PendingStop {
@@ -123,6 +126,19 @@ struct PendingStop {
123126
stop_message: Option<String>,
124127
}
125128

129+
const LOST_STOP_MESSAGE: &str = "envoy connection lost";
130+
131+
impl PendingStop {
132+
/// A stop that escalates to lost must still report `Stopped(Error)`, even if it started as a
133+
/// graceful stop. See `begin_stop` for why `Stopped(Ok)` would destroy the actor.
134+
fn mark_lost(&mut self) {
135+
self.stop_code = protocol::StopCode::Error;
136+
if self.stop_message.is_none() {
137+
self.stop_message = Some(LOST_STOP_MESSAGE.to_string());
138+
}
139+
}
140+
}
141+
126142
enum StopProgress {
127143
Stopped,
128144
Pending(PendingStop),
@@ -135,8 +151,13 @@ pub fn create_actor(
135151
config: protocol::ActorConfig,
136152
hibernating_requests: Vec<protocol::HibernatingRequest>,
137153
preloaded_kv: Option<protocol::PreloadedKv>,
138-
) -> (mpsc::UnboundedSender<ToActor>, Arc<AsyncCounter>) {
154+
) -> (
155+
mpsc::UnboundedSender<ToActor>,
156+
CancellationToken,
157+
Arc<AsyncCounter>,
158+
) {
139159
let (tx, rx) = mpsc::unbounded_channel();
160+
let lost = CancellationToken::new();
140161
let active_http_request_count = Arc::new(AsyncCounter::new());
141162
spawn_detached(actor_inner(
142163
shared,
@@ -148,8 +169,9 @@ pub fn create_actor(
148169
tx.clone(),
149170
rx,
150171
active_http_request_count.clone(),
172+
lost.clone(),
151173
));
152-
(tx, active_http_request_count)
174+
(tx, lost, active_http_request_count)
153175
}
154176

155177
#[tracing::instrument(
@@ -171,6 +193,7 @@ async fn actor_inner(
171193
tx: mpsc::UnboundedSender<ToActor>,
172194
mut rx: mpsc::UnboundedReceiver<ToActor>,
173195
active_http_request_count: Arc<AsyncCounter>,
196+
lost: CancellationToken,
174197
) {
175198
let handle = EnvoyHandle {
176199
shared: shared.clone(),
@@ -191,6 +214,7 @@ async fn actor_inner(
191214
ws_entries: BufferMap::new(),
192215
hibernating_requests,
193216
active_http_request_count,
217+
lost,
194218
};
195219
let mut http_request_tasks = JoinSet::new();
196220
let mut pending_stop: Option<PendingStop> = None;
@@ -200,12 +224,13 @@ async fn actor_inner(
200224
let start_result = shared
201225
.config
202226
.callbacks
203-
.on_actor_start(
227+
.on_actor_start_with_lost_signal(
204228
handle.clone(),
205229
actor_id.clone(),
206230
generation,
207231
config,
208232
preloaded_kv,
233+
ctx.lost.clone(),
209234
)
210235
.await;
211236

@@ -290,11 +315,20 @@ async fn actor_inner(
290315
command_idx,
291316
reason,
292317
} => {
293-
if pending_stop.is_some() {
294-
tracing::warn!(
295-
command_idx,
296-
"ignoring duplicate stop while actor teardown is in progress"
297-
);
318+
if let Some(pending) = pending_stop.as_mut() {
319+
if matches!(reason, protocol::StopActorReason::Lost) {
320+
tracing::warn!(
321+
command_idx,
322+
"lost stop escalates actor teardown already in progress"
323+
);
324+
ctx.lost.cancel();
325+
pending.mark_lost();
326+
} else {
327+
tracing::warn!(
328+
command_idx,
329+
"ignoring duplicate stop while actor teardown is in progress"
330+
);
331+
}
298332
continue;
299333
}
300334
if command_idx <= ctx.command_idx {
@@ -308,20 +342,23 @@ async fn actor_inner(
308342
}
309343
}
310344
ToActor::Lost => {
311-
if pending_stop.is_some() {
312-
tracing::warn!(
313-
"ignoring lost signal while actor teardown is in progress"
314-
);
345+
ctx.lost.cancel();
346+
if let Some(pending) = pending_stop.as_mut() {
347+
tracing::warn!("lost signal escalates actor teardown already in progress");
348+
pending.mark_lost();
315349
continue;
316350
}
317351

318352
ctx.error = Some("actor lost due to timeout".to_string());
319353

354+
// The engine treats this generation as lost once the envoy has been
355+
// disconnected past the lost threshold, so stop it the same way as an
356+
// engine-sent Lost.
320357
match begin_stop(
321358
&mut ctx,
322359
&handle,
323360
&mut http_request_tasks,
324-
protocol::StopActorReason::SleepIntent,
361+
protocol::StopActorReason::Lost,
325362
)
326363
.await
327364
{
@@ -447,10 +484,10 @@ async fn begin_stop(
447484
// `envoy_lost_threshold`.
448485
let (mut stop_code, mut stop_message) = if let Some(err) = ctx.error.clone() {
449486
(protocol::StopCode::Error, Some(err))
450-
} else if matches!(reason, protocol::StopActorReason::Lost) {
487+
} else if matches!(reason, protocol::StopActorReason::Lost) || ctx.lost.is_cancelled() {
451488
(
452489
protocol::StopCode::Error,
453-
Some("envoy connection lost".to_string()),
490+
Some(LOST_STOP_MESSAGE.to_string()),
454491
)
455492
} else {
456493
(protocol::StopCode::Ok, None)
@@ -540,9 +577,17 @@ fn send_stopped_event_for_result(
540577

541578
fn send_stopped_event(
542579
ctx: &mut ActorContext,
543-
stop_code: protocol::StopCode,
544-
stop_message: Option<String>,
580+
mut stop_code: protocol::StopCode,
581+
mut stop_message: Option<String>,
545582
) {
583+
// The lost token is cancelled synchronously before the Lost command is queued, so a graceful
584+
// stop can complete after the generation was lost but before this task consumed the command.
585+
// Recheck here so a lost generation never reports Stopped(Ok), which the engine treats as a
586+
// destroy.
587+
if ctx.lost.is_cancelled() && matches!(stop_code, protocol::StopCode::Ok) {
588+
stop_code = protocol::StopCode::Error;
589+
stop_message = Some(LOST_STOP_MESSAGE.to_string());
590+
}
546591
send_event(
547592
ctx,
548593
protocol::Event::EventActorStateUpdate(protocol::EventActorStateUpdate {
@@ -1717,6 +1762,33 @@ mod tests {
17171762
.expect("timed out waiting for tunnel message")
17181763
}
17191764

1765+
async fn wait_for_stopped_code(
1766+
envoy_rx: &mut mpsc::UnboundedReceiver<ToEnvoyMessage>,
1767+
) -> protocol::StopCode {
1768+
tokio::time::timeout(Duration::from_secs(2), async {
1769+
loop {
1770+
let Some(msg) = envoy_rx.recv().await else {
1771+
panic!("envoy channel closed before stopped event");
1772+
};
1773+
let ToEnvoyMessage::SendEvents { events } = msg else {
1774+
continue;
1775+
};
1776+
for event in events {
1777+
if let protocol::Event::EventActorStateUpdate(
1778+
protocol::EventActorStateUpdate {
1779+
state: protocol::ActorState::ActorStateStopped(stopped),
1780+
},
1781+
) = event.inner
1782+
{
1783+
return stopped.code;
1784+
}
1785+
}
1786+
}
1787+
})
1788+
.await
1789+
.expect("timed out waiting for stopped event")
1790+
}
1791+
17201792
pub(super) async fn wait_for_stopped_event(
17211793
envoy_rx: &mut mpsc::UnboundedReceiver<ToEnvoyMessage>,
17221794
) {
@@ -1823,7 +1895,7 @@ mod tests {
18231895
release_fetch.clone(),
18241896
));
18251897
let (shared, mut envoy_rx) = build_shared_context(callbacks);
1826-
let (actor_tx, active_http_request_count) = create_actor(
1898+
let (actor_tx, _lost, active_http_request_count) = create_actor(
18271899
shared,
18281900
"actor-1".to_string(),
18291901
1,
@@ -1864,7 +1936,7 @@ mod tests {
18641936
let (fetch_dropped_tx, fetch_dropped_rx) = oneshot::channel();
18651937
let callbacks = Arc::new(TestCallbacks::hanging(fetch_started_tx, fetch_dropped_tx));
18661938
let (shared, mut envoy_rx) = build_shared_context(callbacks);
1867-
let (actor_tx, active_http_request_count) = create_actor(
1939+
let (actor_tx, _lost, active_http_request_count) = create_actor(
18681940
shared,
18691941
"actor-2".to_string(),
18701942
1,
@@ -1909,7 +1981,7 @@ mod tests {
19091981
stop_handle_tx: Mutex::new(Some(stop_handle_tx)),
19101982
});
19111983
let (shared, mut envoy_rx) = build_shared_context(callbacks);
1912-
let (actor_tx, _active_http_request_count) = create_actor(
1984+
let (actor_tx, _lost, _active_http_request_count) = create_actor(
19131985
shared,
19141986
"actor-3".to_string(),
19151987
1,
@@ -1942,7 +2014,7 @@ mod tests {
19422014
stop_handle_tx: Mutex::new(Some(stop_handle_tx)),
19432015
});
19442016
let (shared, mut envoy_rx) = build_shared_context(callbacks);
1945-
let (actor_tx, _active_http_request_count) = create_actor(
2017+
let (actor_tx, _lost, _active_http_request_count) = create_actor(
19462018
shared,
19472019
"actor-4".to_string(),
19482020
1,
@@ -1979,6 +2051,112 @@ mod tests {
19792051
assert!(stop_handle.complete(), "stop handle should complete once");
19802052
assert_alarm_before_stopped_event(&mut envoy_rx, Some(123)).await;
19812053
}
2054+
#[tokio::test]
2055+
async fn lost_signal_cancels_the_generation_token() {
2056+
let (stop_handle_tx, _stop_handle_rx) = oneshot::channel();
2057+
let callbacks = Arc::new(DeferredStopCallbacks {
2058+
stop_handle_tx: Mutex::new(Some(stop_handle_tx)),
2059+
});
2060+
let (shared, _envoy_rx) = build_shared_context(callbacks);
2061+
let (actor_tx, lost, _active_http_request_count) = create_actor(
2062+
shared,
2063+
"actor-lost-token".to_string(),
2064+
1,
2065+
actor_config(),
2066+
Vec::new(),
2067+
None,
2068+
);
2069+
assert!(!lost.is_cancelled());
2070+
2071+
actor_tx.send(ToActor::Lost).expect("failed to send lost");
2072+
tokio::time::timeout(Duration::from_secs(2), lost.cancelled())
2073+
.await
2074+
.expect("lost signal should cancel the generation token");
2075+
}
2076+
2077+
#[tokio::test]
2078+
async fn lost_during_graceful_stop_reports_stopped_error() {
2079+
let (stop_handle_tx, stop_handle_rx) = oneshot::channel();
2080+
let callbacks = Arc::new(DeferredStopCallbacks {
2081+
stop_handle_tx: Mutex::new(Some(stop_handle_tx)),
2082+
});
2083+
let (shared, mut envoy_rx) = build_shared_context(callbacks);
2084+
let (actor_tx, lost, _active_http_request_count) = create_actor(
2085+
shared,
2086+
"actor-lost-escalation".to_string(),
2087+
1,
2088+
actor_config(),
2089+
Vec::new(),
2090+
None,
2091+
);
2092+
2093+
actor_tx
2094+
.send(ToActor::Stop {
2095+
command_idx: 1,
2096+
reason: protocol::StopActorReason::SleepIntent,
2097+
})
2098+
.expect("failed to send sleep stop");
2099+
let stop_handle = tokio::time::timeout(Duration::from_secs(2), stop_handle_rx)
2100+
.await
2101+
.expect("timed out waiting for stop handle")
2102+
.expect("stop handle sender dropped");
2103+
2104+
// The engine gives up on the generation while its graceful sleep is still running.
2105+
actor_tx
2106+
.send(ToActor::Stop {
2107+
command_idx: 2,
2108+
reason: protocol::StopActorReason::Lost,
2109+
})
2110+
.expect("failed to send lost stop");
2111+
tokio::time::timeout(Duration::from_secs(2), lost.cancelled())
2112+
.await
2113+
.expect("lost stop should cancel the generation token");
2114+
2115+
assert!(stop_handle.complete(), "stop handle should complete once");
2116+
assert_eq!(
2117+
wait_for_stopped_code(&mut envoy_rx).await,
2118+
protocol::StopCode::Error,
2119+
"an escalated stop must not report Stopped(Ok), which the engine treats as a destroy"
2120+
);
2121+
}
2122+
2123+
#[tokio::test]
2124+
async fn graceful_stop_completing_before_lost_command_is_consumed_reports_error() {
2125+
let (stop_handle_tx, stop_handle_rx) = oneshot::channel();
2126+
let callbacks = Arc::new(DeferredStopCallbacks {
2127+
stop_handle_tx: Mutex::new(Some(stop_handle_tx)),
2128+
});
2129+
let (shared, mut envoy_rx) = build_shared_context(callbacks);
2130+
let (actor_tx, lost, _active_http_request_count) = create_actor(
2131+
shared,
2132+
"actor-lost-race".to_string(),
2133+
1,
2134+
actor_config(),
2135+
Vec::new(),
2136+
None,
2137+
);
2138+
2139+
actor_tx
2140+
.send(ToActor::Stop {
2141+
command_idx: 1,
2142+
reason: protocol::StopActorReason::SleepIntent,
2143+
})
2144+
.expect("failed to send sleep stop");
2145+
let stop_handle = tokio::time::timeout(Duration::from_secs(2), stop_handle_rx)
2146+
.await
2147+
.expect("timed out waiting for stop handle")
2148+
.expect("stop handle sender dropped");
2149+
2150+
// The command path cancels the token before queueing Lost. Complete the graceful stop in
2151+
// that window, before the actor task sees the Lost command.
2152+
lost.cancel();
2153+
assert!(stop_handle.complete(), "stop handle should complete once");
2154+
assert_eq!(
2155+
wait_for_stopped_code(&mut envoy_rx).await,
2156+
protocol::StopCode::Error,
2157+
"a lost generation must not report Stopped(Ok) even if the Lost command was not consumed"
2158+
);
2159+
}
19822160
}
19832161

19842162
#[cfg(test)]

0 commit comments

Comments
 (0)