Skip to content

Commit fde1cea

Browse files
committed
fix(llm-request-router): keep the previous expression per target
Gateway replicas cache model metadata separately, so after an owner update they send the old and new expressions alternately for up to the auth cache TTL. With one slot per target every switch rebuilt the definition, reset the load-balancer state, and logged a build line. Each entry now holds its current and previous expression; either one is a hit. A third expression moves current to previous and retires only the older definition. Expiry and size eviction retire both. Relates to #1404 Signed-off-by: along <along@nvidia.com>
1 parent ae1ab68 commit fde1cea

2 files changed

Lines changed: 103 additions & 30 deletions

File tree

‎src/libraries/rust/stargate/crates/stargate/src/load_balancer/dynamic_config.rs‎

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use std::sync::Arc;
55
use std::time::Duration;
66

7+
use moka::notification::RemovalCause;
78
use moka::ops::compute::{CompResult, Op};
89
use moka::sync::Cache;
910

@@ -12,11 +13,35 @@ use crate::routing_state::{RoutingTargetKey, StargateState};
1213
use super::expression::RejectionError;
1314
use super::target_state::LoadBalancerDefinition;
1415

15-
struct DynamicConfigEntry {
16+
#[derive(Clone)]
17+
struct Configuration {
1618
expression: String,
1719
definition: LoadBalancerDefinition,
1820
}
1921

22+
// Gateway replicas cache model metadata separately, so after an owner update they send the
23+
// old and new expressions alternately until their caches converge. Keeping the previous
24+
// expression makes that window a run of hits instead of a rebuild per switch.
25+
struct DynamicConfigEntry {
26+
current: Configuration,
27+
previous: Option<Configuration>,
28+
}
29+
30+
impl DynamicConfigEntry {
31+
fn definition_for(&self, header: &str) -> Option<&LoadBalancerDefinition> {
32+
std::iter::once(&self.current)
33+
.chain(&self.previous)
34+
.find(|configuration| configuration.expression == header)
35+
.map(|configuration| &configuration.definition)
36+
}
37+
38+
// A replacement carries `current` into the new entry as its `previous`.
39+
fn dropped(&self, cause: RemovalCause) -> impl Iterator<Item = &Configuration> {
40+
let current = (cause != RemovalCause::Replaced).then_some(&self.current);
41+
current.into_iter().chain(&self.previous)
42+
}
43+
}
44+
2045
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2146
pub(crate) enum Outcome {
2247
Hit,
@@ -49,9 +74,11 @@ impl DynamicConfigCache {
4974
.max_capacity(max_entries)
5075
.time_to_idle(idle)
5176
.eviction_listener(
52-
move |target: Arc<RoutingTargetKey>, entry: Arc<DynamicConfigEntry>, _| {
53-
entry.definition.retire();
54-
state.forget_load_balancer_instance(&target, &entry.definition);
77+
move |target: Arc<RoutingTargetKey>, entry: Arc<DynamicConfigEntry>, cause| {
78+
for configuration in entry.dropped(cause) {
79+
configuration.definition.retire();
80+
state.forget_load_balancer_instance(&target, &configuration.definition);
81+
}
5582
},
5683
)
5784
.build(),
@@ -66,23 +93,24 @@ impl DynamicConfigCache {
6693
) -> Result<(LoadBalancerDefinition, Outcome), RejectionError> {
6794
// Hits skip the per-key compute lock so they never wait behind a rebuild.
6895
if let Some(entry) = self.entries.get(target)
69-
&& entry.expression == header
96+
&& let Some(definition) = entry.definition_for(header)
7097
{
71-
return Ok((entry.definition.clone(), Outcome::Hit));
98+
return Ok((definition.clone(), Outcome::Hit));
7299
}
73100
let result = self
74101
.entries
75102
.entry_by_ref(target)
76-
.and_try_compute_with(|current| {
77-
if current
78-
.as_ref()
79-
.is_some_and(|entry| entry.value().expression == header)
80-
{
103+
.and_try_compute_with(|existing| {
104+
let existing = existing.as_ref().map(|entry| entry.value());
105+
if existing.is_some_and(|entry| entry.definition_for(header).is_some()) {
81106
return Ok(Op::Nop);
82107
}
83108
Ok(Op::Put(Arc::new(DynamicConfigEntry {
84-
expression: header.to_owned(),
85-
definition: build()?,
109+
current: Configuration {
110+
expression: header.to_owned(),
111+
definition: build()?,
112+
},
113+
previous: existing.map(|entry| entry.current.clone()),
86114
})))
87115
})?;
88116
let (entry, outcome) = match result {
@@ -94,7 +122,12 @@ impl DynamicConfigCache {
94122
unreachable!("dynamic config compute returned no entry")
95123
}
96124
};
97-
Ok((entry.into_value().definition.clone(), outcome))
125+
let definition = entry
126+
.into_value()
127+
.definition_for(header)
128+
.cloned()
129+
.expect("a hit matched this expression and a build stored it as current");
130+
Ok((definition, outcome))
98131
}
99132

100133
#[cfg(test)]
@@ -207,7 +240,7 @@ mod tests {
207240
}
208241

209242
#[tokio::test]
210-
async fn test_resolve_changed_bytes_forgets_only_superseded_instance() {
243+
async fn test_resolve_keeps_previous_expression_and_forgets_older_ones() {
211244
let state = Arc::new(StargateState::new());
212245
let target = RoutingTargetKey::new(None, "model");
213246
let snapshot = register_target(&state, &target).await;
@@ -219,27 +252,54 @@ mod tests {
219252
.resolve(&target, EXPRESSION, || compile(EXPRESSION))
220253
.unwrap();
221254
let first_instance = snapshot.load_balancers().load_balancer(&first);
222-
assert_eq!(snapshot.load_balancers().instance_count(), 2);
223255

224-
// Equivalent values with different bytes must still replace the entry.
256+
// Equivalent values with different bytes still build a new configuration.
225257
let changed = "ROUND_ROBIN; require_input_tokens=false";
226258
let (second, outcome) = cache
227259
.resolve(&target, changed, || compile(changed))
228260
.unwrap();
229261
assert_eq!(outcome, Outcome::Rebuild);
230262
assert_ne!(first, second);
231-
assert_eq!(snapshot.load_balancers().instance_count(), 1);
232263
let second_instance = snapshot.load_balancers().load_balancer(&second);
233-
assert!(!Arc::ptr_eq(&first_instance, &second_instance));
264+
assert_eq!(snapshot.load_balancers().instance_count(), 3);
265+
266+
// Gateway replicas alternating between the two values only hit.
267+
for _ in 0..4 {
268+
for (header, definition, instance) in [
269+
(EXPRESSION, &first, &first_instance),
270+
(changed, &second, &second_instance),
271+
] {
272+
let (resolved, outcome) = cache
273+
.resolve(&target, header, || panic!("alternation rebuilt {header}"))
274+
.unwrap();
275+
assert_eq!((&resolved, outcome), (definition, Outcome::Hit));
276+
assert!(Arc::ptr_eq(
277+
instance,
278+
&snapshot.load_balancers().load_balancer(&resolved)
279+
));
280+
}
281+
}
282+
283+
// A third value keeps the current one as previous and forgets the older one.
284+
let third = "round-robin;require_input_tokens=true";
285+
let (third_definition, outcome) = cache.resolve(&target, third, || compile(third)).unwrap();
286+
assert_eq!(outcome, Outcome::Rebuild);
287+
assert!(!snapshot.load_balancers().contains(&first));
288+
assert!(snapshot.load_balancers().contains(&second));
289+
assert_eq!(
290+
cache.resolve(&target, changed, || panic!("previous rebuilt")),
291+
Ok((second.clone(), Outcome::Hit))
292+
);
293+
assert_ne!(third_definition, second);
234294
assert!(Arc::ptr_eq(
235295
&static_instance,
236296
&snapshot.load_balancers().load_balancer(&static_definition)
237297
));
238298

239-
// A request paused after resolution must not reinsert an evicted definition.
299+
// A request paused after resolution must not reinsert a forgotten definition.
240300
let _in_flight = snapshot.load_balancers().load_balancer(&first);
241-
assert_eq!(snapshot.load_balancers().instance_count(), 2);
242301
assert!(!snapshot.load_balancers().contains(&first));
302+
assert_eq!(snapshot.load_balancers().instance_count(), 2);
243303
}
244304

245305
#[tokio::test]
@@ -328,16 +388,19 @@ mod tests {
328388
let snapshot = register_target(&state, &target).await;
329389
let cache =
330390
DynamicConfigCache::new(state, Duration::from_secs(60), DYNAMIC_CONFIG_MAX_ENTRIES);
331-
let (mut current, _) = cache
391+
let (mut older, _) = cache
332392
.resolve(&target, EXPRESSION, || compile(EXPRESSION))
333393
.unwrap();
334-
for value in 1..=32 {
394+
let header = "round-robin;max_input_work_seconds=1";
395+
let (mut newer, _) = cache.resolve(&target, header, || compile(header)).unwrap();
396+
for value in 2..=33 {
335397
let barrier = Barrier::new(2);
336398
let header = format!("round-robin;max_input_work_seconds={value}");
399+
// The next rebuild drops `older`; race a selection on it against that rebuild.
337400
let next = std::thread::scope(|scope| {
338401
let selection = scope.spawn(|| {
339402
barrier.wait();
340-
snapshot.load_balancers().load_balancer(&current)
403+
snapshot.load_balancers().load_balancer(&older)
341404
});
342405
barrier.wait();
343406
let (next, outcome) = cache
@@ -347,9 +410,10 @@ mod tests {
347410
let _in_flight = selection.join().unwrap();
348411
next
349412
});
350-
assert!(!snapshot.load_balancers().contains(&current));
413+
assert!(!snapshot.load_balancers().contains(&older));
351414
assert_eq!(snapshot.load_balancers().instance_count(), 0);
352-
current = next;
415+
older = newer;
416+
newer = next;
353417
}
354418
}
355419

@@ -364,9 +428,16 @@ mod tests {
364428
.resolve(&target, EXPRESSION, || compile(EXPRESSION))
365429
.unwrap();
366430
let _instance = snapshot.load_balancers().load_balancer(&first);
431+
let changed = "round-robin;require_input_tokens=true";
432+
let (previous_holder, _) = cache
433+
.resolve(&target, changed, || compile(changed))
434+
.unwrap();
435+
let _instance = snapshot.load_balancers().load_balancer(&previous_holder);
436+
assert_eq!(snapshot.load_balancers().instance_count(), 2);
367437
cache.run_pending_tasks();
368438
tokio::time::sleep(Duration::from_millis(100)).await;
369439
cache.run_pending_tasks();
440+
// Expiry forgets both the current and the previous configuration.
370441
assert!(cache.entries.get(&target).is_none());
371442
assert_eq!(snapshot.load_balancers().instance_count(), 0);
372443
let (second, outcome) = cache
@@ -456,7 +527,7 @@ mod tests {
456527
cache
457528
.entries
458529
.get(&target)
459-
.map(|entry| entry.definition.clone()),
530+
.map(|entry| entry.current.definition.clone()),
460531
before
461532
);
462533
}

‎src/libraries/rust/stargate/docs/load-balancer-configuration.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -441,9 +441,11 @@ An invalid header returns HTTP `400` before backend selection, with
441441
`{"error":"<message>","code":"<class>"}`. The message names the rejected
442442
parameter or value.
443443

444-
Each routing key and model reuses one configuration and load balancer while its
445-
expression bytes are unchanged. Entries expire after 15 idle minutes, and each
446-
router process keeps at most 16384.
444+
Each routing key and model keeps the configurations and load balancers for its
445+
two most recent expressions, compared by bytes, so values that alternate while
446+
an update propagates reuse them. A third expression replaces the older one.
447+
Entries expire after 15 idle minutes, and each router process keeps at most
448+
16384.
447449

448450
## Load-balancer request headers
449451

0 commit comments

Comments
 (0)