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