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