skippy: make WAN split serve without direct-return, fix per-stream RTT gate

The verify-window pipelining path hard-required a direct-prediction-return
sink, so a WAN split that could not open that sink 502'd every request
instead of serving. Root cause: open_stage_transport_stream re-applied the
formation-time MAX_SPLIT_RTT_MS (80ms) eligibility ceiling to every fresh
operational stream. Pooled forward lanes (opened once at low RTT and reused)
kept working, but per-request direct-return sinks opened a fresh bridge stream
each request and were rejected under normal WAN RTT jitter, aborting the bridge
task and surfacing as a ready-handshake timeout on stage 0.

Changes:
- Relax the native-MTP verify guard: serve serially over the forward lane when
  direct-return is unavailable (serial completion already polls both the direct
  receiver and the forward lane). Keep pipelining gated on a confirmed direct
  sink, since its completion path is direct-only.
- Stop re-litigating formation-time path eligibility on post-formation
  operational streams in open_stage_transport_stream; warn and proceed. Split
  admission still gates eligibility using gossiped, hysteresis-smoothed RTT plus
  re-election. Transient degradation slows a stream; it no longer kills it.
- Bounded, privacy-safe telemetry: classify_direct_return_failure_phase emits a
  phase label instead of raw endpoints/error text in OTLP. Raw detail stays in
  local stderr only. New stage.verify_window_serial_fallback event.

Tests: 6 new classifier unit tests; existing direct_return, split_stage_path,
split_readiness, and stage_transport suites pass.
This commit is contained in:
Michael Neale 2026-07-21 09:01:39 +10:00
parent e48d4583bf
commit c340f74135
2 changed files with 169 additions and 12 deletions

View file

@ -1045,11 +1045,29 @@ impl Node {
skippy_protocol::validate_stage_transport_open(&open)
.map_err(|e| anyhow::anyhow!("StageTransportOpen validation error: {e}"))?;
let conn = self.stage_connection_to_peer(peer_id).await?;
let snapshot = split_stage_path_snapshot_from_connection(&conn)
.with_peer_path_fallback(self.peer_stage_path_fallback(peer_id).await);
if let Some(rejection) = snapshot.stage_path_rejection() {
anyhow::bail!(
"stage transport path to {} is not eligible for split serving: {}",
// Do NOT re-apply the formation-time RTT/path eligibility ceiling
// (MAX_SPLIT_RTT_MS) to operational streams here. This function only runs
// from the post-formation stage-transport bridge, and split admission
// already gates path eligibility using gossiped, hysteresis-smoothed peer
// RTT plus re-election (see api/split_readiness.rs and node RTT handling).
//
// Re-checking the *instantaneous* per-connection RTT snapshot on every
// fresh stream caused transient failures under normal WAN jitter: pooled
// forward lanes (opened once at low RTT and reused) kept working, while
// per-request direct-prediction-return sinks — which open a fresh bridge
// stream each request — were rejected whenever the snapshot briefly read
// above the ceiling. That aborted the bridge task, dropped the accepted
// return-sink TCP connection, and surfaced as a ready-handshake timeout on
// stage 0, degrading/502-ing an already-admitted split. A transiently slow
// or relay path still *works* (just slower); sustained degradation is
// handled by re-election, not by killing individual operational streams.
if let Some(rejection) = split_stage_path_snapshot_from_connection(&conn)
.with_peer_path_fallback(self.peer_stage_path_fallback(peer_id).await)
.stage_path_rejection()
{
tracing::warn!(
"stage transport path to {} reports {} on a formed split; proceeding \
(operational streams tolerate transient path degradation)",
peer_id.fmt_short(),
rejection.as_str()
);

View file

@ -73,6 +73,7 @@ impl StageOpenAiBackend {
.ok_or_else(|| OpenAiError::backend("embedded stage 0 has no downstream lane pool"))?;
let mut lane = lane_pool.checkout(request.ids)?;
let mut direct_prediction_return_opened = false;
let mut direct_prediction_return_error: Option<String> = None;
if let Some(prediction_return) = request.prediction_return.as_ref() {
match crate::binary_transport::direct_return::open_downstream_prediction_return_stream(
request.config,
@ -85,9 +86,28 @@ impl StageOpenAiBackend {
direct_prediction_return_opened = true;
}
Err(error) => {
let detail = format!("{error:#}");
// Raw endpoints + full error chain: local stderr only (never OTLP/502).
eprintln!(
"direct prediction return upstream-opened sink unavailable: {error:#}"
"direct prediction return upstream-opened sink unavailable \
(stage_bind_addr={}, downstream={:?}): {detail}",
request.config.bind_addr,
request.config.downstream.as_ref().map(|d| &d.endpoint),
);
// OTLP: bounded, privacy-safe fields only.
let failure_phase = classify_direct_return_failure_phase(&detail);
let mut attrs = self.openai_attrs(request.ids);
attrs.insert(
"llama_stage.direct_prediction_return.opened".to_string(),
json!(false),
);
attrs.insert(
"llama_stage.direct_prediction_return.failure_phase".to_string(),
json!(failure_phase),
);
self.telemetry
.emit("stage.direct_prediction_return_unavailable", attrs);
direct_prediction_return_error = Some(failure_phase.to_string());
}
}
}
@ -895,14 +915,42 @@ impl StageOpenAiBackend {
native_mtp_options.ngram_hybrid && draft_guard.is_none();
let native_mtp_verify_windows_enabled =
(request.native_mtp_enabled || composite_sidecar_enabled) && draft_guard.is_none();
let pipelined_decode_enabled =
composite_sidecar_enabled && verify_window_scheduler.depth() > 1;
// Availability/performance separation (per docs/skippy/PIPELINED_VERIFY_WINDOW.md and
// expert review): the forward lane makes the split *servable*; direct prediction return
// makes it *pipeline-capable*. Serial VerifyWindow completion polls BOTH the direct
// return receiver and the forward lane (see embedded_execution.rs
// `poll_direct_or_downstream_reply`), so it serves correctly without a direct sink —
// only slower. The pipelined path's completion is direct-only and would block up to
// DIRECT_RETURN_FALLBACK_TIMEOUT (300s) waiting for a reply that never comes, so
// pipelining MUST stay gated on a confirmed direct-return sink.
let pipelined_decode_enabled = direct_prediction_return_opened
&& composite_sidecar_enabled
&& verify_window_scheduler.depth() > 1;
if native_mtp_verify_windows_enabled && !direct_prediction_return_opened {
return Err(OpenAiError::backend(
"native MTP verify windows require direct prediction return",
));
// Not fatal: fall back to serial VerifyWindow over the forward lane. Record why the
// pipeline is unavailable so a WAN run can see it in one response, without failing
// the request.
let mut attrs = self.openai_attrs(request.ids);
attrs.insert(
"llama_stage.direct_prediction_return.opened".to_string(),
json!(false),
);
attrs.insert(
"llama_stage.verify_window.pipeline_available".to_string(),
json!(false),
);
attrs.insert(
"llama_stage.direct_prediction_return.failure_phase".to_string(),
json!(
direct_prediction_return_error
.as_deref()
.unwrap_or("not_configured")
),
);
self.telemetry
.emit("stage.verify_window_serial_fallback", attrs);
}
if native_mtp_verify_windows_enabled {
if native_mtp_verify_windows_enabled && direct_prediction_return_opened {
verify_window_scheduler.mark_direct_prediction_return();
}
let mut pipelined_windows = VecDeque::new();
@ -1998,3 +2046,94 @@ impl StageOpenAiBackend {
Ok(cache_stats)
}
}
/// Classify a direct-prediction-return sink-open failure into a bounded,
/// privacy-safe phase label for telemetry. Raw endpoints and full error chains
/// stay in local stderr logs only; OTLP attributes must not carry them.
///
/// Phase meanings (see docs/skippy/PIPELINED_VERIFY_WINDOW.md and the return-sink
/// open sequence in `binary_transport::direct_return::open_return_sink_once`):
/// - `connect_refused` — local bridge listener gone/wrong (no one accepted).
/// - `connect_timeout` — TCP connect to the (local bridge) endpoint timed out.
/// - `ready_timeout` — local connect ok; no `ready` handshake byte in time
/// (bridge/QUIC/remote-accept problem — the WAN leg).
/// - `remote_eof` — remote/bridge accepted then closed before handshake.
/// - `open_write_failed` — handshake ok but the open message write failed.
/// - `other` — anything else; inspect local logs.
fn classify_direct_return_failure_phase(error_detail: &str) -> &'static str {
let lower = error_detail.to_ascii_lowercase();
if lower.contains("did not become ready") || lower.contains("ready read timeout") {
"ready_timeout"
} else if lower.contains("connection refused") {
"connect_refused"
} else if lower.contains("timed out") || lower.contains("timeout") {
"connect_timeout"
} else if lower.contains("unexpectedeof")
|| lower.contains("unexpected eof")
|| lower.contains("connection reset")
{
"remote_eof"
} else if lower.contains("open prediction return stream") || lower.contains("broken pipe") {
"open_write_failed"
} else {
"other"
}
}
#[cfg(test)]
mod direct_return_failure_phase_tests {
use super::classify_direct_return_failure_phase;
#[test]
fn maps_ready_timeout_the_wan_leg() {
assert_eq!(
classify_direct_return_failure_phase(
"downstream prediction return sink did not become ready"
),
"ready_timeout"
);
}
#[test]
fn maps_connect_refused_local_bridge_gone() {
assert_eq!(
classify_direct_return_failure_phase(
"connect downstream prediction return sink at 127.0.0.1:54321: Connection refused (os error 61)"
),
"connect_refused"
);
}
#[test]
fn maps_remote_eof() {
assert_eq!(
classify_direct_return_failure_phase("read direct prediction return: UnexpectedEof"),
"remote_eof"
);
}
#[test]
fn maps_open_write_failed() {
assert_eq!(
classify_direct_return_failure_phase("open prediction return stream: broken pipe"),
"open_write_failed"
);
}
#[test]
fn unknown_is_other() {
assert_eq!(
classify_direct_return_failure_phase("some unexpected condition"),
"other"
);
}
#[test]
fn connect_timeout_distinct_from_ready_timeout() {
// A bare connect timeout (no "did not become ready") is the connect phase.
assert_eq!(
classify_direct_return_failure_phase("connect ...: operation timed out"),
"connect_timeout"
);
}
}