1use std::collections::BTreeMap;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6
7use superfluid_proto::linkf::Lease;
8
9use crate::fleet::{FleetGeneration, FleetHead};
10use crate::wal::GenParams;
11use crate::DaemonError;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PlacementPolicy {
15 LoadAware,
16 LeastLoaded,
17 RoundRobin,
18}
19
20#[derive(Clone, Copy, Default)]
21struct NodeLoad {
22 kv_used: u64,
23 kv_total: u64,
24 lanes_active: u32,
25 queue_depth: u32,
26 decode_tokens_per_s: u32,
27 prefill_tokens_per_s: u32,
28 at: Option<std::time::Instant>,
29}
30
31impl NodeLoad {
32 const TTL: std::time::Duration = std::time::Duration::from_secs(10);
33
34 fn fresh(&self) -> bool {
35 self.at.is_some_and(|t| t.elapsed() < Self::TTL)
36 }
37
38 fn pool_pct(&self) -> Option<u64> {
39 (self.fresh() && self.kv_total > 0).then(|| self.kv_used * 100 / self.kv_total)
40 }
41
42 fn eta_ms(&self, inflight: u32, prompt_tokens: u64, expected_out: u64, typical: (u64, u64)) -> u64 {
45 let ahead = if self.fresh() {
46 self.lanes_active as u64 + self.queue_depth as u64 + inflight as u64
47 } else {
48 inflight as u64
49 };
50 const NOMINAL_DECODE: u64 = 20;
51 const NOMINAL_PREFILL: u64 = 200;
52 let or = |reported: u32, typical: u64, nominal: u64| match (reported, typical) {
55 (0, 0) => nominal,
56 (0, t) => t,
57 (r, _) => r as u64,
58 };
59 let decode = or(self.decode_tokens_per_s, typical.0, NOMINAL_DECODE);
60 let prefill = or(self.prefill_tokens_per_s, typical.1, NOMINAL_PREFILL);
61 let queued = ahead.saturating_mul(expected_out).saturating_mul(1_000) / decode;
62 let mine = prompt_tokens.saturating_mul(1_000) / prefill
63 + expected_out.saturating_mul(1_000) / decode;
64 queued.saturating_add(mine)
65 }
66}
67
68fn watch(node: &Arc<NodeConn>, head: &mut FleetHead) {
70 let node = Arc::downgrade(node);
71 head.on_status(Arc::new(move |cap| {
72 if let Some(node) = node.upgrade() {
73 *node.live.lock().expect("live") = FleetManager::load_from(cap);
74 }
75 }));
76}
77
78#[derive(Debug, Clone)]
79pub struct NodeSpec {
80 pub addr: String,
81 pub auth: Vec<u8>,
82}
83
84struct NodeConn {
85 spec: NodeSpec,
86 conns: Vec<Mutex<Option<FleetHead>>>,
87 open: std::sync::atomic::AtomicUsize,
89 cursor: std::sync::atomic::AtomicUsize,
90 decoded: std::sync::atomic::AtomicU64,
92 live: Mutex<NodeLoad>,
95}
96
97impl NodeMeta {
98 fn new() -> NodeMeta {
99 NodeMeta {
100 models: Vec::new(),
101 identity: String::new(),
102 peer: None,
103 down_since: None,
104 last_retry: None,
105 learned: false,
106 max_context_tokens: 0,
107 load: NodeLoad::default(),
108 }
109 }
110
111 fn up(&self) -> bool {
112 self.down_since.is_none()
113 }
114}
115
116impl NodeConn {
117 fn checkout_next(&self) -> (usize, std::sync::MutexGuard<'_, Option<FleetHead>>) {
118 let open = self.open.load(std::sync::atomic::Ordering::Relaxed).clamp(1, self.conns.len());
119 let i = self.cursor.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % open;
120 (i, self.conns[i].lock().expect("node conn"))
121 }
122
123 fn checkout_at(&self, i: usize) -> std::sync::MutexGuard<'_, Option<FleetHead>> {
124 self.conns[i.min(self.conns.len() - 1)].lock().expect("node conn")
125 }
126
127 fn reset(&self) {
128 for slot in &self.conns {
129 if let Ok(mut g) = slot.try_lock() {
130 *g = None;
131 }
132 }
133 }
134}
135
136#[derive(Clone)]
137struct NodeMeta {
138 models: Vec<String>,
139 identity: String,
140 peer: Option<std::net::IpAddr>,
142 down_since: Option<std::time::Instant>,
144 last_retry: Option<std::time::Instant>,
145 learned: bool,
146 max_context_tokens: u64,
147 load: NodeLoad,
148}
149
150#[derive(Debug, Clone)]
151struct Placed {
152 node: usize,
153 params: GenParams,
154 qos_class: u8,
155 codec: String,
156 model: String,
157 tokens: Vec<u32>,
158 slot: usize,
159 epoch: u64,
160 parked: bool,
161}
162
163struct Shared {
164 meta: Vec<NodeMeta>,
165 forgets: Vec<(usize, usize, u64, u64)>,
167 sessions: BTreeMap<u64, Placed>,
168 rr_cursor: usize,
169 cancels: BTreeMap<u64, Arc<AtomicBool>>,
170 inflight: Vec<u32>,
171 claims: BTreeMap<u64, usize>,
172}
173
174pub struct FleetManager {
175 nodes: std::sync::RwLock<Vec<Arc<NodeConn>>>,
176 conns_by_lanes: bool,
177 retry_every: std::time::Duration,
178 last_keepalive: Mutex<std::time::Instant>,
179 shared: Mutex<Shared>,
180 policy: PlacementPolicy,
181 lease: Lease,
182 pool_high_pct: u32,
183}
184
185struct InFlight<'a> {
186 shared: &'a Mutex<Shared>,
187 node: usize,
188 session: u64,
189}
190
191impl<'a> InFlight<'a> {
192 fn claim(shared: &'a Mutex<Shared>, node: usize, session: u64) {
193 let mut sh = shared.lock().expect("shared");
194 if let Some(prev) = sh.claims.insert(session, node) {
195 if prev != node {
196 if let Some(n) = sh.inflight.get_mut(prev) {
197 *n = n.saturating_sub(1);
198 }
199 } else {
200 return;
201 }
202 }
203 if let Some(n) = sh.inflight.get_mut(node) {
204 *n = n.saturating_add(1);
205 }
206 }
207
208 fn release(shared: &Mutex<Shared>, session: u64) {
209 let mut sh = shared.lock().expect("shared");
210 if let Some(node) = sh.claims.remove(&session) {
211 if let Some(n) = sh.inflight.get_mut(node) {
212 *n = n.saturating_sub(1);
213 }
214 }
215 }
216
217 fn guard(shared: &'a Mutex<Shared>, node: usize, session: u64) -> InFlight<'a> {
218 Self::claim(shared, node, session);
219 InFlight { shared, node, session }
220 }
221}
222
223impl Drop for InFlight<'_> {
224 fn drop(&mut self) {
225 let _ = self.node;
226 Self::release(self.shared, self.session);
227 }
228}
229
230#[derive(Clone, Copy, Default)]
231pub struct RequestShape {
232 pub prompt_tokens: u64,
233 pub expected_out: u64,
234}
235
236pub const DEFAULT_POOL_HIGH_PCT: u32 = 90;
237
238pub const DEFAULT_CONNS_PER_NODE: usize = 0;
240
241const MAX_CONNS_PER_NODE: usize = 64;
242
243const JOINED: &str = "joined:";
245
246pub const RETRY_DOWN_AFTER: std::time::Duration = std::time::Duration::from_secs(10);
248
249pub const KEEPALIVE_EVERY: std::time::Duration = std::time::Duration::from_secs(15);
251
252#[derive(Debug, Clone)]
254pub struct NodeStatus {
255 pub name: String,
257 pub addr: String,
259 pub up: bool,
260 pub lanes: usize,
262 pub lanes_active: u32,
264 pub queue_depth: u32,
265 pub kv_blocks_used: u64,
266 pub kv_blocks_total: u64,
267 pub decode_tokens: u64,
268}
269
270#[derive(Debug, Clone)]
271pub struct NodeReport {
272 pub addr: String,
273 pub identity: String,
274 pub models: Vec<String>,
275 pub max_context_tokens: u64,
276 pub max_lanes: u32,
277}
278
279impl FleetManager {
280 pub fn new(nodes: Vec<NodeSpec>, policy: PlacementPolicy, lease: Lease) -> FleetManager {
281 Self::with_pool_high(nodes, policy, lease, DEFAULT_POOL_HIGH_PCT)
282 }
283
284 pub fn with_pool_high(
285 nodes: Vec<NodeSpec>,
286 policy: PlacementPolicy,
287 lease: Lease,
288 pool_high_pct: u32,
289 ) -> FleetManager {
290 Self::with_options(nodes, policy, lease, pool_high_pct, DEFAULT_CONNS_PER_NODE)
291 }
292
293 pub fn with_options(
294 nodes: Vec<NodeSpec>,
295 policy: PlacementPolicy,
296 lease: Lease,
297 pool_high_pct: u32,
298 conns_per_node: usize,
299 ) -> FleetManager {
300 let meta = vec![NodeMeta::new(); nodes.len()];
301 let node_count = nodes.len();
302 let nodes = nodes
303 .into_iter()
304 .map(|spec| {
305 Arc::new(NodeConn {
306 spec,
307 conns: (0..if conns_per_node == 0 { MAX_CONNS_PER_NODE } else { conns_per_node })
308 .map(|_| Mutex::new(None))
309 .collect(),
310 open: std::sync::atomic::AtomicUsize::new(conns_per_node.max(1)),
311 cursor: std::sync::atomic::AtomicUsize::new(0),
312 decoded: std::sync::atomic::AtomicU64::new(0),
313 live: Mutex::new(NodeLoad::default()),
314 })
315 })
316 .collect();
317 FleetManager {
318 nodes: std::sync::RwLock::new(nodes),
319 conns_by_lanes: conns_per_node == 0,
320 retry_every: RETRY_DOWN_AFTER,
321 last_keepalive: Mutex::new(std::time::Instant::now()),
322 shared: Mutex::new(Shared {
323 meta,
324 sessions: BTreeMap::new(),
325 forgets: Vec::new(),
326 rr_cursor: 0,
327 inflight: vec![0; node_count],
328 claims: BTreeMap::new(),
329 cancels: BTreeMap::new(),
330 }),
331 policy,
332 lease,
333 pool_high_pct: pool_high_pct.clamp(1, 100),
334 }
335 }
336
337 fn node(&self, i: usize) -> Arc<NodeConn> {
338 Arc::clone(&self.nodes.read().expect("nodes")[i])
339 }
340
341 fn node_count(&self) -> usize {
342 self.nodes.read().expect("nodes").len()
343 }
344
345 pub fn adopt(&self, head: FleetHead, peer: std::net::IpAddr) -> Result<(usize, bool, usize), String> {
353 let identity = head.node().node_identity.clone();
354 let lanes = (head.node().capacity.max_lanes as usize).max(1);
355 let (i, new) = {
356 let mut sh = self.shared.lock().expect("shared");
357 match sh.meta.iter().position(|m| m.identity == identity) {
358 Some(i) => {
359 let node = self.node(i);
360 let connected = node.conns.iter().any(|c| c.lock().expect("node conn").is_some());
361 if connected && sh.meta[i].peer.is_some_and(|p| p != peer) {
362 return Err(format!(
363 "another node named '{identity}' is joined from {}; give this one --identity",
364 sh.meta[i].peer.expect("peer")
365 ));
366 }
367 sh.meta[i].peer = Some(peer);
368 (i, false)
369 }
370 None => {
371 let mut nodes = self.nodes.write().expect("nodes");
372 nodes.push(Arc::new(NodeConn {
373 spec: NodeSpec { addr: format!("{JOINED}{identity}"), auth: Vec::new() },
374 conns: (0..lanes).map(|_| Mutex::new(None)).collect(),
375 open: std::sync::atomic::AtomicUsize::new(1),
376 cursor: std::sync::atomic::AtomicUsize::new(0),
377 decoded: std::sync::atomic::AtomicU64::new(0),
378 live: Mutex::new(NodeLoad::default()),
379 }));
380 let mut meta = NodeMeta::new();
383 meta.identity = identity.clone();
384 meta.peer = Some(peer);
385 meta.down_since = Some(std::time::Instant::now());
386 sh.meta.push(meta);
387 sh.inflight.push(0);
388 (nodes.len() - 1, true)
389 }
390 }
391 };
392 self.sync_meta(i, &head);
393 let node = self.node(i);
394 let mut head = head;
395 watch(&node, &mut head);
396 let mut slot = 0;
397 for (s, conn) in node.conns.iter().enumerate() {
398 if conn.lock().expect("node conn").is_none() {
399 slot = s;
400 break;
401 }
402 }
403 *node.checkout_at(slot) = Some(head);
404 let open = node.conns.iter().filter(|c| c.lock().expect("node conn").is_some()).count();
405 node.open.store(open.max(1), std::sync::atomic::Ordering::Relaxed);
406 if new {
407 self.shared.lock().expect("shared").meta[i].down_since = None;
409 } else {
410 self.mark_up(i);
411 }
412 Ok((i, new, open))
413 }
414
415 pub fn connections(&self, i: usize) -> usize {
417 self.node(i).open.load(std::sync::atomic::Ordering::Relaxed)
418 }
419
420 pub fn load(&self) -> Vec<usize> {
421 let sh = self.shared.lock().expect("shared");
422 let mut counts = vec![0usize; self.node_count()];
423 for p in sh.sessions.values() {
424 if !p.parked {
425 counts[p.node] += 1;
426 }
427 }
428 counts
429 }
430
431 pub fn status(&self) -> Vec<NodeStatus> {
433 let nodes: Vec<Arc<NodeConn>> = self.nodes.read().expect("nodes").clone();
434 let sh = self.shared.lock().expect("shared");
435 nodes
436 .iter()
437 .zip(&sh.meta)
438 .zip(&sh.inflight)
439 .map(|((node, meta), &inflight)| {
440 let live = *node.live.lock().expect("live");
441 let load = if live.at > meta.load.at { live } else { meta.load };
442 let addr = match (&meta.peer, node.spec.addr.strip_prefix(JOINED)) {
443 (Some(peer), Some(_)) => peer.to_string(),
444 _ => node.spec.addr.clone(),
445 };
446 NodeStatus {
447 name: if meta.identity.is_empty() { addr.clone() } else { meta.identity.clone() },
448 addr,
449 up: meta.up() && meta.learned,
450 lanes: node.open.load(Ordering::Relaxed),
451 lanes_active: inflight,
452 queue_depth: load.queue_depth,
453 kv_blocks_used: load.kv_used,
454 kv_blocks_total: load.kv_total,
455 decode_tokens: node.decoded.load(Ordering::Relaxed),
456 }
457 })
458 .collect()
459 }
460
461 pub fn node_of(&self, session: u64) -> Option<String> {
462 let sh = self.shared.lock().expect("shared");
463 let node = sh.sessions.get(&session)?.node;
464 Some(sh.meta[node].identity.clone())
465 }
466
467 pub fn with_retry_every(mut self, every: std::time::Duration) -> FleetManager {
470 self.retry_every = every;
471 self
472 }
473
474 fn mark_unhealthy(&self, i: usize, why: &dyn std::fmt::Display) {
475 let mut sh = self.shared.lock().expect("shared");
476 if sh.meta[i].down_since.is_none() {
477 let now = std::time::Instant::now();
478 sh.meta[i].down_since = Some(now);
479 sh.meta[i].last_retry = Some(now);
480 tracing::warn!("fleet: node {} is down ({why}); retrying it in the background", self.label(&sh, i));
481 }
482 }
483
484 fn mark_up(&self, i: usize) {
485 let mut sh = self.shared.lock().expect("shared");
486 if let Some(since) = sh.meta[i].down_since.take() {
487 tracing::info!("fleet: node {} is back after {} s", self.label(&sh, i), since.elapsed().as_secs());
488 }
489 }
490
491 fn label(&self, sh: &Shared, i: usize) -> String {
492 let node = self.node(i);
493 let addr = &node.spec.addr;
494 match sh.meta[i].identity.as_str() {
495 "" => addr.clone(),
496 id => format!("{id} ({addr})"),
497 }
498 }
499
500 fn identity(&self, i: usize) -> String {
501 let sh = self.shared.lock().expect("shared");
502 self.label(&sh, i)
503 }
504
505 fn active_node(&self, session: u64) -> Result<usize, DaemonError> {
506 let sh = self.shared.lock().expect("shared");
507 let p = sh
508 .sessions
509 .get(&session)
510 .ok_or(DaemonError::UnknownSession(session))?;
511 if p.parked {
512 return Err(DaemonError::Config("session is parked; resume it first"));
513 }
514 Ok(p.node)
515 }
516
517 fn ensure_connected(&self, i: usize, guard: &mut Option<FleetHead>) -> Result<(), DaemonError> {
518 if guard.is_some() {
519 return Ok(());
520 }
521 let node = self.node(i);
522 let spec = &node.spec;
523 if spec.addr.starts_with(JOINED) {
524 let e = DaemonError::FleetNode { addr: spec.addr.clone(), why: "left; waiting for it to join again".into() };
525 self.mark_unhealthy(i, &e);
526 return Err(e);
527 }
528 match FleetHead::connect(&spec.addr, spec.auth.clone()) {
529 Ok(mut head) => {
530 watch(&node, &mut head);
531 self.sync_meta(i, &head);
532 *guard = Some(head);
533 Ok(())
534 }
535 Err(e) => {
536 self.mark_unhealthy(i, &e);
537 Err(e)
538 }
539 }
540 }
541
542 fn rebind(&self, session: u64, head: &mut FleetHead) -> Result<(), DaemonError> {
545 if head.knows(session) {
546 return Ok(());
547 }
548 let p = self
549 .shared
550 .lock()
551 .expect("shared")
552 .sessions
553 .get(&session)
554 .cloned()
555 .ok_or(DaemonError::UnknownSession(session))?;
556 let epoch = head.assign(session, p.params, p.qos_class, &p.codec, self.lease)?;
557 if !p.tokens.is_empty() {
558 head.append(session, &p.tokens)?;
559 }
560 if let Some(p) = self.shared.lock().expect("shared").sessions.get_mut(&session) {
561 p.epoch = epoch;
562 }
563 Ok(())
564 }
565
566 fn rebind_on(&self, session: u64, guard: &mut Option<FleetHead>) -> Result<(), DaemonError> {
569 let r = self.rebind(session, guard.as_mut().expect("connected"));
570 if let Err(e) = &r {
571 if is_fatal_node(e) {
572 *guard = None;
573 }
574 }
575 r
576 }
577
578 pub fn advertised_context(&self, model: &str) -> Option<u64> {
579 let sh = self.shared.lock().expect("shared");
580 (0..self.node_count())
581 .filter(|&i| sh.meta[i].up() && Self::serves(&sh, i, model))
582 .map(|i| sh.meta[i].max_context_tokens)
583 .filter(|&c| c > 0)
584 .max()
585 }
586
587 fn serves(sh: &Shared, i: usize, model: &str) -> bool {
588 !sh.meta[i].learned || sh.meta[i].models.iter().any(|m| m == model)
589 }
590
591 fn holds(sh: &Shared, i: usize, len: u64) -> bool {
592 let max = sh.meta[i].max_context_tokens;
593 max == 0 || len <= max
594 }
595
596 fn length_refusal(&self, cand: &[usize], model: &str, len: u64) -> Option<DaemonError> {
597 let sh = self.shared.lock().expect("shared");
598 let eligible: Vec<usize> =
599 cand.iter().copied().filter(|&i| Self::serves(&sh, i, model)).collect();
600 if eligible.is_empty() || eligible.iter().any(|&i| Self::holds(&sh, i, len)) {
601 return None;
602 }
603 Some(DaemonError::StreamTooLong {
604 len,
605 max: eligible.iter().map(|&i| sh.meta[i].max_context_tokens).max().unwrap_or(0),
606 })
607 }
608
609 fn sync_meta(&self, i: usize, head: &FleetHead) {
610 let mut sh = self.shared.lock().expect("shared");
611 let cap = &head.node().capacity;
612 sh.meta[i].models = head.node().loadable_models.clone();
613 sh.meta[i].max_context_tokens = cap.max_context_tokens;
614 sh.meta[i].identity = head.node().node_identity.clone();
615 sh.meta[i].learned = true;
616 sh.meta[i].load = Self::load_from(cap);
617 let node = self.node(i);
618 if self.conns_by_lanes && !node.spec.addr.starts_with(JOINED) {
619 let (lanes, pool) = (cap.max_lanes.max(1) as usize, node.conns.len());
620 let was = node.open.swap(lanes.min(pool), std::sync::atomic::Ordering::Relaxed);
621 if lanes > pool && was != pool {
622 tracing::warn!(
623 "fleet: node {} has {lanes} lanes; the head opens at most {pool} connections to it",
624 self.label(&sh, i)
625 );
626 }
627 }
628 }
629
630 fn load_from(cap: &superfluid_proto::handshake::CapacitySummary) -> NodeLoad {
631 NodeLoad {
632 kv_used: cap.kv_blocks_used,
633 kv_total: cap.kv_blocks_total,
634 lanes_active: cap.lanes_active,
635 queue_depth: cap.queue_depth,
636 decode_tokens_per_s: cap.decode_tokens_per_s,
637 prefill_tokens_per_s: cap.prefill_tokens_per_s,
638 at: Some(std::time::Instant::now()),
639 }
640 }
641
642 fn replacement_shape(tokens: &[u32], expected_out: u64) -> RequestShape {
643 RequestShape {
644 prompt_tokens: tokens.len() as u64,
645 expected_out,
646 }
647 }
648
649 fn candidates_claiming(
650 &self,
651 model: &str,
652 claim_for: Option<u64>,
653 shape: RequestShape,
654 ) -> Vec<usize> {
655 let mut sh = self.shared.lock().expect("shared");
656 let mut load = vec![0usize; self.node_count()];
657 for p in sh.sessions.values() {
658 if !p.parked {
659 load[p.node] += 1;
660 }
661 }
662 let mut cand: Vec<usize> = (0..self.node_count())
663 .filter(|&i| sh.meta[i].up() && Self::serves(&sh, i, model))
664 .collect();
665 match self.policy {
666 PlacementPolicy::LoadAware => {
667 let fits: Vec<usize> = cand
668 .iter()
669 .copied()
670 .filter(|&i| Self::holds(&sh, i, shape.prompt_tokens))
671 .collect();
672 let judged: &[usize] = if fits.is_empty() { &cand } else { &fits };
673 let hot: Vec<usize> = judged
674 .iter()
675 .copied()
676 .filter(|&i| {
677 sh.meta[i].load.pool_pct().is_some_and(|p| p >= self.pool_high_pct as u64)
678 })
679 .collect();
680 if hot.len() < judged.len() {
681 cand.retain(|i| !hot.contains(i));
682 }
683 let inflight = sh.inflight.clone();
684 let mean = |rate: fn(&NodeLoad) -> u32| {
685 let known: Vec<u64> =
686 cand.iter().map(|&i| rate(&sh.meta[i].load) as u64).filter(|&r| r > 0).collect();
687 known.iter().sum::<u64>() / (known.len() as u64).max(1)
688 };
689 let typical = (mean(|l| l.decode_tokens_per_s), mean(|l| l.prefill_tokens_per_s));
690 cand.sort_by_key(|&i| {
691 (
692 sh.meta[i].load.eta_ms(inflight[i], shape.prompt_tokens, shape.expected_out, typical),
693 load[i],
694 )
695 });
696 }
697 PlacementPolicy::LeastLoaded => {
698 let inflight = sh.inflight.clone();
699 cand.sort_by_key(|&i| load[i] + inflight[i] as usize);
700 }
701 PlacementPolicy::RoundRobin => {
702 let n = self.node_count().max(1);
703 let c = sh.rr_cursor % n;
704 cand.sort_by_key(|&i| (i + n - c) % n);
705 sh.rr_cursor = sh.rr_cursor.wrapping_add(1);
706 }
707 }
708 if let (Some(session), Some(&winner)) = (claim_for, cand.first()) {
709 Self::claim_locked(&mut sh, winner, session);
710 }
711 cand
712 }
713
714 fn checkout_live(node_conn: &NodeConn, slot: usize) -> std::sync::MutexGuard<'_, Option<FleetHead>> {
717 let mut guard = node_conn.checkout_at(slot);
718 if guard.as_mut().is_some_and(|h| h.drain_status().is_err()) {
719 *guard = None;
720 }
721 guard
722 }
723
724 fn slot_of(&self, session: u64) -> usize {
725 self.shared
726 .lock()
727 .expect("shared")
728 .sessions
729 .get(&session)
730 .map(|p| p.slot)
731 .unwrap_or(0)
732 }
733
734 fn claim_locked(sh: &mut Shared, node: usize, session: u64) {
735 if let Some(prev) = sh.claims.insert(session, node) {
736 if prev == node {
737 return;
738 }
739 if let Some(n) = sh.inflight.get_mut(prev) {
740 *n = n.saturating_sub(1);
741 }
742 }
743 if let Some(n) = sh.inflight.get_mut(node) {
744 *n = n.saturating_add(1);
745 }
746 }
747
748 #[allow(clippy::too_many_arguments)]
749 pub fn place(
750 &self,
751 session: u64,
752 params: GenParams,
753 qos_class: u8,
754 codec: &str,
755 model: &str,
756 tokens: &[u32],
757 expected_out: u64,
758 ) -> Result<usize, DaemonError> {
759 let cand = self.candidates_claiming(
760 model,
761 Some(session),
762 RequestShape {
763 prompt_tokens: tokens.len() as u64,
764 expected_out,
765 },
766 );
767 let release_on_error = |e: DaemonError| {
768 InFlight::release(&self.shared, session);
769 e
770 };
771 if cand.is_empty() {
772 return Err(release_on_error(DaemonError::Config(
773 "no healthy node can serve the model",
774 )));
775 }
776 let len = tokens.len() as u64;
777 if let Some(e) = self.length_refusal(&cand, model, len) {
778 return Err(release_on_error(e));
779 }
780 let mut last_err = None;
781 for &i in &cand {
782 match self.try_place_on(i, session, params, qos_class, codec, model, tokens) {
783 Ok(true) => {
784 tracing::info!(
785 "fleet: session {session} ({} prompt tokens) placed on {}",
786 tokens.len(),
787 self.identity(i)
788 );
789 return Ok(i);
790 }
791 Ok(false) => continue,
792 Err(e) => {
793 self.mark_unhealthy(i, &e);
794 self.node(i).reset();
795 last_err = Some(e);
796 }
797 }
798 }
799 if let Some(e) = self.length_refusal(&cand, model, len) {
800 return Err(release_on_error(e));
801 }
802 Err(release_on_error(last_err.unwrap_or(DaemonError::Config(
803 "no reachable node serves the model",
804 ))))
805 }
806
807 #[allow(clippy::too_many_arguments)]
808 fn try_place_on(
809 &self,
810 i: usize,
811 session: u64,
812 params: GenParams,
813 qos_class: u8,
814 codec: &str,
815 model: &str,
816 tokens: &[u32],
817 ) -> Result<bool, DaemonError> {
818 let node = self.node(i);
819 let (slot, mut guard) = node.checkout_next();
820 if let Some(h) = guard.as_mut() {
821 if h.drain_status().is_err() {
822 *guard = None;
823 }
824 }
825 self.ensure_connected(i, &mut guard)?;
826 self.flush_forgets(i, slot, guard.as_mut().expect("connected"));
827 self.sync_meta(i, guard.as_ref().expect("connected"));
828 {
829 let sh = self.shared.lock().expect("shared");
830 if !Self::serves(&sh, i, model) {
831 return Ok(false);
832 }
833 if !Self::holds(&sh, i, tokens.len() as u64) {
834 return Ok(false);
835 }
836 }
837 let head = guard.as_mut().expect("connected");
838 let epoch = match head.assign(session, params, qos_class, codec, self.lease) {
839 Ok(epoch) => epoch,
840 Err(e) => {
841 *guard = None;
842 return Err(e);
843 }
844 };
845 if !tokens.is_empty() {
846 if let Err(e) = head.append(session, tokens) {
847 *guard = None;
848 return Err(e);
849 }
850 }
851 InFlight::claim(&self.shared, i, session);
852 self.shared.lock().expect("shared").sessions.insert(
853 session,
854 Placed {
855 node: i,
856 params,
857 qos_class,
858 codec: codec.to_string(),
859 model: model.to_string(),
860 tokens: tokens.to_vec(),
861 slot,
862 epoch,
863 parked: false,
864 },
865 );
866 Ok(true)
867 }
868
869 pub fn append(&self, session: u64, tokens: &[u32]) -> Result<(), DaemonError> {
870 let node = self.active_node(session)?;
871 let len = tokens.len() as u64;
872 let fits = {
873 let sh = self.shared.lock().expect("shared");
874 Self::holds(&sh, node, len)
875 };
876 if !fits {
877 return self.re_place_grown(session, node, tokens);
878 }
879 {
880 let node_conn = self.node(node);
881 let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
882 self.ensure_connected(node, &mut guard)?;
883 self.rebind_on(session, &mut guard)?;
884 let head = guard.as_mut().expect("connected");
885 if let Err(e) = head.append(session, tokens) {
886 *guard = None;
887 return Err(e);
888 }
889 self.sync_meta(node, head);
890 }
891 if let Some(p) = self.shared.lock().expect("shared").sessions.get_mut(&session) {
892 p.tokens = tokens.to_vec();
893 }
894 Ok(())
895 }
896
897 fn re_place_grown(
898 &self,
899 session: u64,
900 from: usize,
901 tokens: &[u32],
902 ) -> Result<(), DaemonError> {
903 let placed = {
904 let sh = self.shared.lock().expect("shared");
905 sh.sessions.get(&session).cloned().ok_or(DaemonError::UnknownSession(session))?
906 };
907 let cand: Vec<usize> = self
908 .candidates_claiming(
909 &placed.model,
910 Some(session),
911 Self::replacement_shape(tokens, 0),
912 )
913 .into_iter()
914 .filter(|&i| i != from)
915 .collect();
916 let len = tokens.len() as u64;
917 if let Some(e) = self.length_refusal(&cand, &placed.model, len) {
918 return Err(e);
919 }
920 for &i in &cand {
921 match self.try_place_on(
922 i,
923 session,
924 placed.params,
925 placed.qos_class,
926 &placed.codec,
927 &placed.model,
928 tokens,
929 ) {
930 Ok(true) => return Ok(()),
931 Ok(false) => continue,
932 Err(e) => {
933 self.mark_unhealthy(i, &e);
934 self.node(i).reset();
935 }
936 }
937 }
938 if let Some(e) = self.length_refusal(&cand, &placed.model, len) {
939 return Err(e);
940 }
941 Err(DaemonError::Config(
942 "no reachable node can hold the session's grown context",
943 ))
944 }
945
946 pub fn generate(
947 &self,
948 session: u64,
949 max_new_tokens: u64,
950 max_wall_ms: u64,
951 ) -> Result<FleetGeneration, DaemonError> {
952 let node = self.active_node(session)?;
953
954 match self.generate_on(node, session, max_new_tokens, max_wall_ms) {
955 Ok(g) => return Ok(g),
956 Err(e) if !is_fatal_node(&e) => return Err(e),
957 Err(e) => self.mark_unhealthy(node, &e),
958 }
959 self.node(node).reset();
960 let placed = self
961 .shared
962 .lock()
963 .expect("shared")
964 .sessions
965 .get(&session)
966 .cloned()
967 .expect("placed session");
968 let cand: Vec<usize> = self
969 .candidates_claiming(
970 &placed.model,
971 Some(session),
972 Self::replacement_shape(&placed.tokens, max_new_tokens),
973 )
974 .into_iter()
975 .filter(|&i| i != node)
976 .collect();
977 for i in cand {
978 match self.try_place_on(
979 i,
980 session,
981 placed.params,
982 placed.qos_class,
983 &placed.codec,
984 &placed.model,
985 &placed.tokens,
986 ) {
987 Ok(true) => {}
988 Ok(false) => continue,
989 Err(e) => {
990 self.mark_unhealthy(i, &e);
991 self.node(i).reset();
992 continue;
993 }
994 }
995 tracing::warn!("fleet: session {session} moved to {}", self.identity(i));
996 match self.generate_on(i, session, max_new_tokens, max_wall_ms) {
997 Ok(g) => return Ok(g),
998 Err(e) => self.mark_unhealthy(i, &e),
999 }
1000 self.node(i).reset();
1001 }
1002 Err(DaemonError::Generation(
1003 "generation failed and no healthy node could take over the session".into(),
1004 ))
1005 }
1006
1007 fn map_refusal(
1008 &self,
1009 node: usize,
1010 session: u64,
1011 r: Result<FleetGeneration, DaemonError>,
1012 ) -> Result<FleetGeneration, DaemonError> {
1013 let g = r?;
1014 if g.finish == crate::nodeagent::FINISH_DECLINED {
1015 return Err(DaemonError::Generation(format!(
1016 "node {} declined session {session} (stale epoch or replayed command)",
1017 self.shared.lock().expect("shared").meta[node].identity
1018 )));
1019 }
1020 if g.finish != crate::nodeagent::FINISH_REFUSED {
1021 return Ok(g);
1022 }
1023 let sh = self.shared.lock().expect("shared");
1024 let len = sh.sessions.get(&session).map(|p| p.tokens.len() as u64).unwrap_or(0);
1025 Err(DaemonError::StreamTooLong {
1026 len,
1027 max: sh.meta[node].max_context_tokens,
1028 })
1029 }
1030
1031 fn generate_on(
1032 &self,
1033 node: usize,
1034 session: u64,
1035 max_new_tokens: u64,
1036 max_wall_ms: u64,
1037 ) -> Result<FleetGeneration, DaemonError> {
1038 let _inflight = InFlight::guard(&self.shared, node, session);
1039 let node_conn = self.node(node);
1040 let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
1041 self.ensure_connected(node, &mut guard)?;
1042 self.rebind_on(session, &mut guard)?;
1043 let head = guard.as_mut().expect("connected");
1044 let r = head.generate(session, max_new_tokens, max_wall_ms);
1045 if let Ok(g) = &r {
1046 node_conn.decoded.fetch_add(g.tokens.len() as u64, Ordering::Relaxed);
1047 }
1048 if let Err(e) = &r {
1049 if is_fatal_node(e) {
1050 *guard = None;
1051 }
1052 }
1053 if let Some(head) = guard.as_ref() {
1054 self.sync_meta(node, head);
1055 }
1056 drop(guard);
1057 self.map_refusal(node, session, r)
1058 }
1059
1060 pub fn generate_streaming(
1061 &self,
1062 session: u64,
1063 max_new_tokens: u64,
1064 max_wall_ms: u64,
1065 mut on_tokens: impl FnMut(&[u32]),
1066 ) -> Result<FleetGeneration, DaemonError> {
1067 let (node, flag) = {
1068 let mut sh = self.shared.lock().expect("shared");
1069 let p = sh
1070 .sessions
1071 .get(&session)
1072 .ok_or(DaemonError::UnknownSession(session))?;
1073 if p.parked {
1074 return Err(DaemonError::Config("session is parked; resume it first"));
1075 }
1076 let node = p.node;
1077 let flag = sh
1078 .cancels
1079 .entry(session)
1080 .or_insert_with(|| Arc::new(AtomicBool::new(false)))
1081 .clone();
1082 flag.store(false, Ordering::Relaxed);
1083 (node, flag)
1084 };
1085
1086 let mut all_tokens: Vec<u32> = Vec::new();
1087
1088 match self.generate_streaming_on(node, session, max_new_tokens, max_wall_ms, &flag, |b| {
1089 all_tokens.extend_from_slice(b);
1090 on_tokens(b);
1091 }) {
1092 Ok(g) => return Ok(g),
1093 Err(e) if !is_fatal_node(&e) => return Err(e),
1094 Err(e) => self.mark_unhealthy(node, &e),
1095 }
1096
1097 let placed = self
1098 .shared
1099 .lock()
1100 .expect("shared")
1101 .sessions
1102 .get(&session)
1103 .cloned()
1104 .expect("placed session");
1105 let input = placed.tokens.clone();
1106 let mut tried = vec![node];
1107 loop {
1108 let remaining = max_new_tokens.saturating_sub(all_tokens.len() as u64);
1109 if remaining == 0 {
1110 return Ok(FleetGeneration {
1111 tokens: all_tokens,
1112 finish: superfluid_abi::finish::LENGTH,
1113 expired: false,
1114 });
1115 }
1116 let cand: Vec<usize> = self
1117 .candidates_claiming(
1118 &placed.model,
1119 Some(session),
1120 Self::replacement_shape(&input, remaining),
1121 )
1122 .into_iter()
1123 .filter(|i| !tried.contains(i))
1124 .collect();
1125 if cand.is_empty() {
1126 return Err(DaemonError::Generation(
1127 "streaming generation failed and no healthy node could take over".into(),
1128 ));
1129 }
1130 let i = cand[0];
1131 tried.push(i);
1132 let mut ctx = input.clone();
1133 ctx.extend_from_slice(&all_tokens);
1134 match self.try_place_on(
1135 i,
1136 session,
1137 placed.params,
1138 placed.qos_class,
1139 &placed.codec,
1140 &placed.model,
1141 &ctx,
1142 ) {
1143 Ok(true) => {}
1144 Ok(false) => continue,
1145 Err(e) => {
1146 self.mark_unhealthy(i, &e);
1147 self.node(i).reset();
1148 continue;
1149 }
1150 }
1151 tracing::warn!(
1152 "fleet: session {session} moved to {} after {} streamed tokens",
1153 self.identity(i),
1154 all_tokens.len()
1155 );
1156 match self.generate_streaming_on(i, session, remaining, max_wall_ms, &flag, |b| {
1157 all_tokens.extend_from_slice(b);
1158 on_tokens(b);
1159 }) {
1160 Ok(g) => {
1161 return Ok(FleetGeneration {
1162 tokens: all_tokens,
1163 finish: g.finish,
1164 expired: g.expired,
1165 })
1166 }
1167 Err(e) if !is_fatal_node(&e) => return Err(e),
1168 Err(e) => self.mark_unhealthy(i, &e),
1169 }
1170 }
1171 }
1172
1173 fn generate_streaming_on(
1174 &self,
1175 node: usize,
1176 session: u64,
1177 max_new_tokens: u64,
1178 max_wall_ms: u64,
1179 flag: &AtomicBool,
1180 mut on_tokens: impl FnMut(&[u32]),
1181 ) -> Result<FleetGeneration, DaemonError> {
1182 let _inflight = InFlight::guard(&self.shared, node, session);
1183 let node_conn = self.node(node);
1184 let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
1185 self.ensure_connected(node, &mut guard)?;
1186 self.rebind_on(session, &mut guard)?;
1187 let head = guard.as_mut().expect("connected");
1188 let decoded = &node_conn.decoded;
1189 let r = head.generate_streaming(session, max_new_tokens, max_wall_ms, flag, |b: &[u32]| {
1190 decoded.fetch_add(b.len() as u64, Ordering::Relaxed);
1191 on_tokens(b);
1192 });
1193 if let Err(e) = &r {
1194 if is_fatal_node(e) {
1195 *guard = None;
1196 }
1197 }
1198 if let Some(head) = guard.as_ref() {
1199 self.sync_meta(node, head);
1200 }
1201 drop(guard);
1202 self.map_refusal(node, session, r)
1203 }
1204
1205 pub fn cancel(&self, session: u64) -> Result<(), DaemonError> {
1206 let sh = self.shared.lock().expect("shared");
1207 if !sh.sessions.contains_key(&session) {
1208 return Err(DaemonError::UnknownSession(session));
1209 }
1210 if let Some(flag) = sh.cancels.get(&session) {
1211 flag.store(true, Ordering::Relaxed);
1212 }
1213 Ok(())
1214 }
1215
1216 pub fn park(&self, session: u64) -> Result<(), DaemonError> {
1217 let node = {
1218 let mut sh = self.shared.lock().expect("shared");
1219 let p = sh
1220 .sessions
1221 .get_mut(&session)
1222 .ok_or(DaemonError::UnknownSession(session))?;
1223 if p.parked {
1224 return Ok(());
1225 }
1226 p.parked = true;
1227 (p.node, p.slot, p.epoch)
1228 };
1229 let (node, slot, epoch) = node;
1230 let node_conn = self.node(node);
1231 let mut guard = Self::checkout_live(&node_conn, slot);
1232 if self.ensure_connected(node, &mut guard).is_ok() {
1233 if let Err(e) = guard.as_mut().expect("connected").revoke(session, epoch) {
1234 *guard = None;
1235 self.mark_unhealthy(node, &e);
1236 }
1237 }
1238 Ok(())
1239 }
1240
1241 pub fn resume(&self, session: u64) -> Result<usize, DaemonError> {
1242 let placed = {
1243 let sh = self.shared.lock().expect("shared");
1244 let p = sh
1245 .sessions
1246 .get(&session)
1247 .ok_or(DaemonError::UnknownSession(session))?;
1248 if !p.parked {
1249 return Err(DaemonError::Config("session is not parked"));
1250 }
1251 p.clone()
1252 };
1253 let cand = self.candidates_claiming(
1254 &placed.model,
1255 Some(session),
1256 Self::replacement_shape(&placed.tokens, 0),
1257 );
1258 if cand.is_empty() {
1259 InFlight::release(&self.shared, session);
1260 return Err(DaemonError::Config("no healthy node can serve the model"));
1261 }
1262 let mut last_err = None;
1263 for i in cand {
1264 match self.try_place_on(
1265 i,
1266 session,
1267 placed.params,
1268 placed.qos_class,
1269 &placed.codec,
1270 &placed.model,
1271 &placed.tokens,
1272 ) {
1273 Ok(true) => return Ok(i),
1274 Ok(false) => continue,
1275 Err(e) => {
1276 self.mark_unhealthy(i, &e);
1277 self.node(i).reset();
1278 last_err = Some(e);
1279 }
1280 }
1281 }
1282 Err(last_err.unwrap_or(DaemonError::Config("no reachable node serves the model")))
1283 }
1284
1285 pub fn finish(&self, session: u64) {
1288 let placed = {
1289 let mut sh = self.shared.lock().expect("shared");
1290 sh.cancels.remove(&session);
1291 sh.sessions.remove(&session)
1292 };
1293 InFlight::release(&self.shared, session);
1294 let Some(p) = placed else { return };
1295 self.shared.lock().expect("shared").forgets.push((p.node, p.slot, session, p.epoch));
1296 let node = self.node(p.node);
1297 let lock = node.conns[p.slot].try_lock();
1298 if let Ok(mut guard) = lock {
1299 if let Some(head) = guard.as_mut() {
1300 self.flush_forgets(p.node, p.slot, head);
1301 }
1302 }
1303 }
1304
1305 fn flush_forgets(&self, node: usize, slot: usize, head: &mut FleetHead) {
1306 let due: Vec<(u64, u64)> = {
1307 let mut sh = self.shared.lock().expect("shared");
1308 if sh.forgets.is_empty() {
1309 return;
1310 }
1311 let (due, keep) = sh.forgets.drain(..).partition(|&(n, s, _, _)| n == node && s == slot);
1312 sh.forgets = keep;
1313 due.into_iter().map(|(_, _, session, epoch)| (session, epoch)).collect()
1314 };
1315 for (session, epoch) in due {
1316 let _ = head.revoke(session, epoch);
1317 }
1318 }
1319
1320 pub fn probe(&self) -> Vec<Result<NodeReport, DaemonError>> {
1322 std::thread::scope(|scope| {
1323 let jobs: Vec<_> = (0..self.node_count())
1324 .map(|i| {
1325 scope.spawn(move || {
1326 let node = self.node(i);
1327 let mut guard = node.checkout_at(0);
1328 self.ensure_connected(i, &mut guard)?;
1329 let node = guard.as_ref().expect("connected").node();
1330 Ok(NodeReport {
1331 addr: self.node(i).spec.addr.clone(),
1332 identity: node.node_identity.clone(),
1333 models: node.loadable_models.clone(),
1334 max_context_tokens: node.capacity.max_context_tokens,
1335 max_lanes: node.capacity.max_lanes,
1336 })
1337 })
1338 })
1339 .collect();
1340 jobs.into_iter().map(|j| j.join().expect("probe thread")).collect()
1341 })
1342 }
1343
1344 pub fn keepalive(&self) {
1346 let nodes: Vec<Arc<NodeConn>> = self.nodes.read().expect("nodes").clone();
1347 for (i, node) in nodes.iter().enumerate() {
1348 for (slot, conn) in node.conns.iter().enumerate() {
1349 let Ok(mut guard) = conn.try_lock() else { continue };
1350 let Some(head) = guard.as_mut() else { continue };
1351 self.flush_forgets(i, slot, head);
1352 match head.keepalive() {
1353 Ok(()) => self.sync_meta(i, head),
1354 Err(_) => *guard = None,
1355 }
1356 }
1357 }
1358 }
1359
1360 pub fn retry_down(&self) {
1363 let due: Vec<usize> = {
1364 let mut sh = self.shared.lock().expect("shared");
1365 let now = std::time::Instant::now();
1366 let mut due = Vec::new();
1367 for (i, m) in sh.meta.iter_mut().enumerate() {
1368 if m.down_since.is_some() && m.last_retry.is_none_or(|t| now.duration_since(t) >= self.retry_every) {
1369 m.last_retry = Some(now);
1370 due.push(i);
1371 }
1372 }
1373 due
1374 };
1375 std::thread::scope(|scope| {
1376 for i in due {
1377 scope.spawn(move || {
1378 let node = self.node(i);
1379 let spec = &node.spec;
1380 if spec.addr.starts_with(JOINED) {
1382 return;
1383 }
1384 match FleetHead::connect(&spec.addr, spec.auth.clone()) {
1385 Ok(mut head) => {
1386 watch(&node, &mut head);
1387 self.sync_meta(i, &head);
1388 self.mark_up(i);
1389 if let Ok(mut slot) = node.conns[0].try_lock() {
1390 if slot.is_none() {
1391 *slot = Some(head);
1392 }
1393 }
1394 }
1395 Err(e) => tracing::debug!("fleet: {e}"),
1396 }
1397 });
1398 }
1399 });
1400 }
1401
1402 pub fn tend(&self) {
1403 self.keepalive();
1404 self.retry_down();
1405 }
1406
1407 pub fn spawn_keepalive(self: &Arc<Self>) {
1408 let mgr = Arc::downgrade(self);
1409 let tick = self.retry_every.min(KEEPALIVE_EVERY);
1410 std::thread::Builder::new()
1411 .name("fleet-keepalive".into())
1412 .spawn(move || loop {
1413 std::thread::sleep(tick);
1414 let Some(mgr) = mgr.upgrade() else { return };
1415 mgr.retry_down();
1416 let due = mgr.last_keepalive.lock().expect("keepalive clock").elapsed() >= KEEPALIVE_EVERY;
1417 if due {
1418 mgr.keepalive();
1419 *mgr.last_keepalive.lock().expect("keepalive clock") = std::time::Instant::now();
1420 }
1421 })
1422 .expect("spawn the fleet keepalive");
1423 }
1424}
1425
1426fn is_fatal_node(e: &DaemonError) -> bool {
1427 matches!(
1428 e,
1429 DaemonError::FleetNode { .. }
1430 | DaemonError::Fleet(superfluid_linkf::LinkFError::Closed)
1431 | DaemonError::Fleet(superfluid_linkf::LinkFError::TimedOut)
1432 | DaemonError::Fleet(superfluid_linkf::LinkFError::Io(_))
1433 )
1434}