Skip to main content

superfluid_daemon/
fleet_manager.rs

1//! Head-side multi-node orchestration.
2
3use 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    /// `typical` stands in for a rate this node has not reported yet: the mean of the nodes
44    /// that have, so a node that has served nothing is neither shunned nor flooded.
45    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        // A rate is what the node does when busy, so it stays true while the node idles; only the
54        // load that piles up ahead of a request goes stale.
55        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
69/// Keeps `node`'s live load from each status `head` reads; weak, as the node owns the head.
70fn 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    /// A connection no generation takes, for short calls (a prompt's encoding, a state
89    /// operation) while every lane's connection is held by a generation. The head dials it for
90    /// a node it lists; a joined node dials it when asked, and one that predates the ask has none.
91    control: Mutex<Option<FleetHead>>,
92    /// How many of `conns` are in use: the count asked for, or the node's lanes once learned.
93    open: std::sync::atomic::AtomicUsize,
94    cursor: std::sync::atomic::AtomicUsize,
95    /// Tokens this node has generated for the head, for `/metrics`.
96    decoded: std::sync::atomic::AtomicU64,
97    /// The last status any of its connections read, mid-generation included, for `/metrics`;
98    /// the load placement reads is synced from a connection between calls.
99    live: Mutex<NodeLoad>,
100}
101
102impl NodeMeta {
103    fn new() -> NodeMeta {
104        NodeMeta {
105            models: Vec::new(),
106            identity: String::new(),
107            peer: None,
108            down_since: None,
109            last_retry: None,
110            learned: false,
111            max_context_tokens: 0,
112            load: NodeLoad::default(),
113            tokenizer_refused: Vec::new(),
114        }
115    }
116
117    fn up(&self) -> bool {
118        self.down_since.is_none()
119    }
120}
121
122impl NodeConn {
123    fn checkout_next(&self) -> (usize, std::sync::MutexGuard<'_, Option<FleetHead>>) {
124        let open = self.open.load(std::sync::atomic::Ordering::Relaxed).clamp(1, self.conns.len());
125        let i = self.cursor.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % open;
126        (i, self.conns[i].lock().expect("node conn"))
127    }
128
129    /// A lane's connection for a new session: the next in turn that no call holds (connected,
130    /// or for a node the head lists, one it can dial), else the first of those to come free.
131    /// Taken strictly in turn, a session would wait out a generation still running while
132    /// another lane is idle. None once `deadline` passes with every one still held.
133    fn checkout_free(&self, deadline: Option<std::time::Instant>) -> Option<(usize, std::sync::MutexGuard<'_, Option<FleetHead>>)> {
134        let open = self.open.load(std::sync::atomic::Ordering::Relaxed).clamp(1, self.conns.len());
135        let start = self.cursor.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
136        let dials = !self.spec.addr.starts_with(JOINED);
137        let mut pause = std::time::Duration::from_millis(1);
138        loop {
139            let mut busy = false;
140            for k in 0..open {
141                let i = (start + k) % open;
142                match self.conns[i].try_lock() {
143                    Ok(guard) if guard.is_some() || dials => return Some((i, guard)),
144                    Ok(_) | Err(std::sync::TryLockError::Poisoned(_)) => {}
145                    Err(std::sync::TryLockError::WouldBlock) => busy = true,
146                }
147            }
148            // None taken and none busy: a joined node's lanes not adopted yet.
149            if !busy {
150                let i = start % open;
151                return Some((i, self.conns[i].lock().expect("node conn")));
152            }
153            if deadline.is_some_and(|at| std::time::Instant::now() >= at) {
154                return None;
155            }
156            std::thread::sleep(pause);
157            pause = (pause * 2).min(std::time::Duration::from_millis(10));
158        }
159    }
160
161    /// A connection no call is using, so a short operation does not wait out a generation:
162    /// a lane's when one is idle, else the control connection, else (a joined node without
163    /// one) the next lane's in turn.
164    fn checkout_idle(&self) -> std::sync::MutexGuard<'_, Option<FleetHead>> {
165        let open = self.open.load(std::sync::atomic::Ordering::Relaxed).clamp(1, self.conns.len());
166        for slot in &self.conns[..open] {
167            if let Ok(guard) = slot.try_lock() {
168                return guard;
169            }
170        }
171        let control = self.control.lock().expect("node conn");
172        if control.is_some() || !self.spec.addr.starts_with(JOINED) {
173            return control;
174        }
175        drop(control);
176        self.checkout_next().1
177    }
178
179    fn checkout_at(&self, i: usize) -> std::sync::MutexGuard<'_, Option<FleetHead>> {
180        self.conns[i.min(self.conns.len() - 1)].lock().expect("node conn")
181    }
182
183    /// As [`Self::checkout_at`], none once `deadline` passes with the slot still held.
184    fn checkout_at_by(
185        &self,
186        i: usize,
187        deadline: Option<std::time::Instant>,
188    ) -> Option<std::sync::MutexGuard<'_, Option<FleetHead>>> {
189        let Some(at) = deadline else { return Some(self.checkout_at(i)) };
190        let slot = &self.conns[i.min(self.conns.len() - 1)];
191        let mut pause = std::time::Duration::from_millis(1);
192        loop {
193            match slot.try_lock() {
194                Ok(guard) => return Some(guard),
195                Err(std::sync::TryLockError::Poisoned(_)) => return Some(self.checkout_at(i)),
196                Err(std::sync::TryLockError::WouldBlock) => {}
197            }
198            let left = at.saturating_duration_since(std::time::Instant::now());
199            if left.is_zero() {
200                return None;
201            }
202            std::thread::sleep(pause.min(left));
203            pause = (pause * 2).min(std::time::Duration::from_millis(10));
204        }
205    }
206
207    fn reset(&self) {
208        for slot in self.conns.iter().chain([&self.control]) {
209            if let Ok(mut g) = slot.try_lock() {
210                *g = None;
211            }
212        }
213    }
214}
215
216#[derive(Clone)]
217struct NodeMeta {
218    models: Vec<String>,
219    identity: String,
220    /// Where a joined node dialed from, so a second machine with its name is told apart.
221    peer: Option<std::net::IpAddr>,
222    /// Set when the node failed; requests pass it over until the background retry connects.
223    down_since: Option<std::time::Instant>,
224    last_retry: Option<std::time::Instant>,
225    learned: bool,
226    max_context_tokens: u64,
227    load: NodeLoad,
228    /// The models the node serves with a tokenizer other than the head's.
229    tokenizer_refused: Vec<String>,
230}
231
232#[derive(Debug, Clone)]
233struct Placed {
234    node: usize,
235    params: GenParams,
236    qos_class: u8,
237    codec: String,
238    model: String,
239    tokens: Vec<u32>,
240    slot: usize,
241    epoch: u64,
242    parked: bool,
243}
244
245struct Shared {
246    meta: Vec<NodeMeta>,
247    /// Finished sessions to revoke on a node: (node, slot, session, epoch).
248    forgets: Vec<(usize, usize, u64, u64)>,
249    sessions: BTreeMap<u64, Placed>,
250    rr_cursor: usize,
251    cancels: BTreeMap<u64, Arc<AtomicBool>>,
252    inflight: Vec<u32>,
253    claims: BTreeMap<u64, usize>,
254}
255
256pub struct FleetManager {
257    nodes: std::sync::RwLock<Vec<Arc<NodeConn>>>,
258    conns_by_lanes: bool,
259    retry_every: std::time::Duration,
260    liveness: Liveness,
261    /// The tokenizer digest the head serves each model with: a node whose differs is not used
262    /// for that model.
263    tokenizers: std::sync::RwLock<std::collections::HashMap<String, u64>>,
264    shared: Mutex<Shared>,
265    /// Which node last ran each prompt prefix, to place the next turn where it is warm.
266    warm: Mutex<crate::fleet_warm::Warm>,
267    policy: PlacementPolicy,
268    lease: Lease,
269    pool_high_pct: u32,
270}
271
272struct InFlight<'a> {
273    shared: &'a Mutex<Shared>,
274    node: usize,
275    session: u64,
276}
277
278impl<'a> InFlight<'a> {
279    fn claim(shared: &'a Mutex<Shared>, node: usize, session: u64) {
280        let mut sh = shared.lock().expect("shared");
281        if let Some(prev) = sh.claims.insert(session, node) {
282            if prev != node {
283                if let Some(n) = sh.inflight.get_mut(prev) {
284                    *n = n.saturating_sub(1);
285                }
286            } else {
287                return;
288            }
289        }
290        if let Some(n) = sh.inflight.get_mut(node) {
291            *n = n.saturating_add(1);
292        }
293    }
294
295    fn release(shared: &Mutex<Shared>, session: u64) {
296        let mut sh = shared.lock().expect("shared");
297        if let Some(node) = sh.claims.remove(&session) {
298            if let Some(n) = sh.inflight.get_mut(node) {
299                *n = n.saturating_sub(1);
300            }
301        }
302    }
303
304    fn guard(shared: &'a Mutex<Shared>, node: usize, session: u64) -> InFlight<'a> {
305        Self::claim(shared, node, session);
306        InFlight { shared, node, session }
307    }
308}
309
310impl Drop for InFlight<'_> {
311    fn drop(&mut self) {
312        let _ = self.node;
313        Self::release(self.shared, self.session);
314    }
315}
316
317#[derive(Clone, Copy, Default)]
318pub struct RequestShape {
319    pub prompt_tokens: u64,
320    pub expected_out: u64,
321    /// The node holding the longest prefix of the prompt warm, tried first while it has a
322    /// lane free.
323    pub warm: Option<usize>,
324}
325
326pub const DEFAULT_POOL_HIGH_PCT: u32 = 90;
327
328/// 0: as many connections to a node as it has lanes, so each lane can be busy at once.
329pub const DEFAULT_CONNS_PER_NODE: usize = 0;
330
331const MAX_CONNS_PER_NODE: usize = 64;
332
333/// The address a joined node is listed under: it dialed in, so the head cannot dial it.
334const JOINED: &str = "joined:";
335
336/// How often the background retry tries a node that failed.
337pub const RETRY_DOWN_AFTER: std::time::Duration = std::time::Duration::from_secs(10);
338
339/// One node as the head sees it now, for `/metrics`.
340#[derive(Debug, Clone)]
341pub struct NodeStatus {
342    /// The node's identity, or the address the head dials while it has not answered.
343    pub name: String,
344    /// The address it joined from or is dialed at.
345    pub addr: String,
346    pub up: bool,
347    /// Connections open to it: its lanes, once it answered.
348    pub lanes: usize,
349    /// Generations the head has in flight there.
350    pub lanes_active: u32,
351    pub queue_depth: u32,
352    pub kv_blocks_used: u64,
353    pub kv_blocks_total: u64,
354    pub decode_tokens: u64,
355}
356
357#[derive(Debug, Clone)]
358pub struct NodeReport {
359    pub addr: String,
360    pub identity: String,
361    pub models: Vec<String>,
362    pub max_context_tokens: u64,
363    pub max_lanes: u32,
364}
365
366impl FleetManager {
367    pub fn new(nodes: Vec<NodeSpec>, policy: PlacementPolicy, lease: Lease) -> FleetManager {
368        Self::with_pool_high(nodes, policy, lease, DEFAULT_POOL_HIGH_PCT)
369    }
370
371    pub fn with_pool_high(
372        nodes: Vec<NodeSpec>,
373        policy: PlacementPolicy,
374        lease: Lease,
375        pool_high_pct: u32,
376    ) -> FleetManager {
377        Self::with_options(nodes, policy, lease, pool_high_pct, DEFAULT_CONNS_PER_NODE)
378    }
379
380    pub fn with_options(
381        nodes: Vec<NodeSpec>,
382        policy: PlacementPolicy,
383        lease: Lease,
384        pool_high_pct: u32,
385        conns_per_node: usize,
386    ) -> FleetManager {
387        let meta = vec![NodeMeta::new(); nodes.len()];
388        let node_count = nodes.len();
389        let nodes = nodes
390            .into_iter()
391            .map(|spec| {
392                Arc::new(NodeConn {
393                    spec,
394                    conns: (0..if conns_per_node == 0 { MAX_CONNS_PER_NODE } else { conns_per_node })
395                        .map(|_| Mutex::new(None))
396                        .collect(),
397                    control: Mutex::new(None),
398                    open: std::sync::atomic::AtomicUsize::new(conns_per_node.max(1)),
399                    cursor: std::sync::atomic::AtomicUsize::new(0),
400                    decoded: std::sync::atomic::AtomicU64::new(0),
401                    live: Mutex::new(NodeLoad::default()),
402                })
403            })
404            .collect();
405        FleetManager {
406            nodes: std::sync::RwLock::new(nodes),
407            conns_by_lanes: conns_per_node == 0,
408            retry_every: RETRY_DOWN_AFTER,
409            liveness: Liveness::DEFAULT,
410            tokenizers: Default::default(),
411            warm: Default::default(),
412            shared: Mutex::new(Shared {
413                meta,
414                sessions: BTreeMap::new(),
415                forgets: Vec::new(),
416                rr_cursor: 0,
417            inflight: vec![0; node_count],
418                claims: BTreeMap::new(),
419                cancels: BTreeMap::new(),
420            }),
421            policy,
422            lease,
423            pool_high_pct: pool_high_pct.clamp(1, 100),
424        }
425    }
426
427    fn node(&self, i: usize) -> Arc<NodeConn> {
428        Arc::clone(&self.nodes.read().expect("nodes")[i])
429    }
430
431    fn node_count(&self) -> usize {
432        self.nodes.read().expect("nodes").len()
433    }
434
435    /// Takes in a node that dialed the head: one seen before, by identity, gets its slot
436    /// back; a new one gets a slot of its own. `true` for a new one.
437    /// Takes in a connection from a node that dialed the head. The node has a slot per lane it
438    /// advertised and fills one per connection it opens, so it runs as many generations at
439    /// once as it dialed; a node seen before, by identity, takes its slots back, unless it is
440    /// still connected from another address, which is a second machine with the same name.
441    /// Returns the node's index, whether it is new, and its open connections.
442    pub fn adopt(&self, head: FleetHead, peer: std::net::IpAddr) -> Result<(usize, bool, usize), String> {
443        let identity = head.node().node_identity.clone();
444        let lanes = (head.node().capacity.max_lanes as usize).max(1);
445        // A new node is listed with its first slot already held here, and let go only once this
446        // connection is in it: a call that reaches the node meanwhile (a probe, a placement)
447        // waits for the connection rather than find the slot empty and take the node for gone.
448        let fresh: Arc<NodeConn>;
449        let mut first = None;
450        let (i, new) = {
451            let mut sh = self.shared.lock().expect("shared");
452            match sh.meta.iter().position(|m| m.identity == identity) {
453                Some(i) => {
454                    let node = self.node(i);
455                    let connected = node.conns.iter().any(in_use);
456                    if connected && sh.meta[i].peer.is_some_and(|p| p != peer) {
457                        return Err(format!(
458                            "another node named '{identity}' is joined from {}; give this one --identity",
459                            sh.meta[i].peer.expect("peer")
460                        ));
461                    }
462                    sh.meta[i].peer = Some(peer);
463                    (i, false)
464                }
465                None => {
466                    fresh = Arc::new(NodeConn {
467                        spec: NodeSpec { addr: format!("{JOINED}{identity}"), auth: Vec::new() },
468                        conns: (0..lanes).map(|_| Mutex::new(None)).collect(),
469                        control: Mutex::new(None),
470                        open: std::sync::atomic::AtomicUsize::new(1),
471                        cursor: std::sync::atomic::AtomicUsize::new(0),
472                        decoded: std::sync::atomic::AtomicU64::new(0),
473                        live: Mutex::new(NodeLoad::default()),
474                    });
475                    first = Some(fresh.conns[0].lock().expect("node conn"));
476                    let mut nodes = self.nodes.write().expect("nodes");
477                    nodes.push(Arc::clone(&fresh));
478                    // Listed as down until its connection is in place, so no request lands
479                    // on an empty slot meanwhile.
480                    let mut meta = NodeMeta::new();
481                    meta.identity = identity.clone();
482                    meta.peer = Some(peer);
483                    meta.down_since = Some(std::time::Instant::now());
484                    sh.meta.push(meta);
485                    sh.inflight.push(0);
486                    (nodes.len() - 1, true)
487                }
488            }
489        };
490        self.sync_meta(i, &head);
491        let node = self.node(i);
492        let mut head = head;
493        head.keep_alive(self.liveness);
494        watch(&node, &mut head);
495        if let Some(mut first) = first {
496            // A new node has no connection for short calls yet.
497            let _ = head.ask_control();
498            *first = Some(head);
499        } else {
500            let slot = node.conns.iter().position(|c| !in_use(c));
501            let has_control = node.control.lock().expect("node conn").is_some();
502            match slot {
503                Some(slot) => {
504                    if !has_control {
505                        let _ = head.ask_control();
506                    }
507                    *node.checkout_at(slot) = Some(head);
508                }
509                // One past its lanes is the connection it dialed when asked.
510                None if !has_control => *node.control.lock().expect("node conn") = Some(head),
511                None => *node.checkout_at(0) = Some(head),
512            }
513        }
514        let open = node.conns.iter().filter(|c| in_use(c)).count();
515        node.open.store(open.max(1), std::sync::atomic::Ordering::Relaxed);
516        if new {
517            // Listed as down only until this connection was in place: not a return.
518            self.shared.lock().expect("shared").meta[i].down_since = None;
519        } else {
520            self.mark_up(i);
521        }
522        Ok((i, new, open))
523    }
524
525    /// The connections a node has open.
526    pub fn connections(&self, i: usize) -> usize {
527        self.node(i).open.load(std::sync::atomic::Ordering::Relaxed)
528    }
529
530    pub fn load(&self) -> Vec<usize> {
531        let sh = self.shared.lock().expect("shared");
532        let mut counts = vec![0usize; self.node_count()];
533        for p in sh.sessions.values() {
534            if !p.parked {
535                counts[p.node] += 1;
536            }
537        }
538        counts
539    }
540
541    /// Every node the head knows, in the order it learned them.
542    pub fn status(&self) -> Vec<NodeStatus> {
543        let nodes: Vec<Arc<NodeConn>> = self.nodes.read().expect("nodes").clone();
544        let sh = self.shared.lock().expect("shared");
545        nodes
546            .iter()
547            .zip(&sh.meta)
548            .zip(&sh.inflight)
549            .map(|((node, meta), &inflight)| {
550                let live = *node.live.lock().expect("live");
551                let load = if live.at > meta.load.at { live } else { meta.load };
552                let addr = match (&meta.peer, node.spec.addr.strip_prefix(JOINED)) {
553                    (Some(peer), Some(_)) => peer.to_string(),
554                    _ => node.spec.addr.clone(),
555                };
556                NodeStatus {
557                    name: if meta.identity.is_empty() { addr.clone() } else { meta.identity.clone() },
558                    addr,
559                    up: meta.up() && meta.learned,
560                    lanes: node.open.load(Ordering::Relaxed),
561                    lanes_active: inflight,
562                    queue_depth: load.queue_depth,
563                    kv_blocks_used: load.kv_used,
564                    kv_blocks_total: load.kv_total,
565                    decode_tokens: node.decoded.load(Ordering::Relaxed),
566                }
567            })
568            .collect()
569    }
570
571    /// The nodes up now that serve `model`, and every node the head knows.
572    /// Nodes up of all the head knows, whatever each serves.
573    pub fn nodes_up(&self) -> (usize, usize) {
574        let sh = self.shared.lock().expect("shared");
575        (sh.meta.iter().filter(|m| m.up() && m.learned).count(), sh.meta.len())
576    }
577
578    pub fn serving(&self, model: &str) -> (usize, usize) {
579        let sh = self.shared.lock().expect("shared");
580        let up = (0..sh.meta.len())
581            .filter(|&i| sh.meta[i].up() && sh.meta[i].learned && Self::serves(&sh, i, model))
582            .count();
583        (up, sh.meta.len())
584    }
585
586    pub fn node_of(&self, session: u64) -> Option<String> {
587        let sh = self.shared.lock().expect("shared");
588        let node = sh.sessions.get(&session)?.node;
589        Some(sh.meta[node].identity.clone())
590    }
591
592    /// How often a node that failed is tried again in the background (default
593    /// [`RETRY_DOWN_AFTER`]).
594    pub fn with_retry_every(mut self, every: std::time::Duration) -> FleetManager {
595        self.retry_every = every;
596        self
597    }
598
599    /// Serves `model` only on nodes whose tokenizer has `digest` ([`crate::fleet::tokenizer_digest`]);
600    /// a node that does not say is taken at its word.
601    pub fn expect_tokenizer(&self, model: &str, digest: u64) {
602        self.tokenizers.write().expect("tokenizers").insert(model.to_string(), digest);
603    }
604
605    /// How the head's connections are kept alive (default [`Liveness::DEFAULT`]); the
606    /// background tend reads each idle one every `heartbeat`.
607    pub fn with_liveness(mut self, liveness: Liveness) -> FleetManager {
608        self.liveness = liveness;
609        self
610    }
611
612    /// Remembers in `path` which node ran each prompt prefix, so a restarted head still places
613    /// a conversation's next turn on the node that has it warm.
614    pub fn with_warm_log(mut self, path: std::path::PathBuf) -> FleetManager {
615        self.warm = Mutex::new(crate::fleet_warm::Warm::kept_in(path));
616        self
617    }
618
619    fn mark_unhealthy(&self, i: usize, why: &dyn std::fmt::Display) {
620        let mut sh = self.shared.lock().expect("shared");
621        if sh.meta[i].down_since.is_none() {
622            let now = std::time::Instant::now();
623            sh.meta[i].down_since = Some(now);
624            sh.meta[i].last_retry = Some(now);
625            tracing::warn!("fleet: node {} is down ({why}); retrying it in the background", self.label(&sh, i));
626        }
627    }
628
629    fn mark_up(&self, i: usize) {
630        let mut sh = self.shared.lock().expect("shared");
631        if let Some(since) = sh.meta[i].down_since.take() {
632            tracing::info!("fleet: node {} is back after {} s", self.label(&sh, i), since.elapsed().as_secs());
633        }
634    }
635
636    fn label(&self, sh: &Shared, i: usize) -> String {
637        let node = self.node(i);
638        let addr = &node.spec.addr;
639        match sh.meta[i].identity.as_str() {
640            "" => addr.clone(),
641            id => format!("{id} ({addr})"),
642        }
643    }
644
645    fn identity(&self, i: usize) -> String {
646        let sh = self.shared.lock().expect("shared");
647        self.label(&sh, i)
648    }
649
650    fn active_node(&self, session: u64) -> Result<usize, DaemonError> {
651        let sh = self.shared.lock().expect("shared");
652        let p = sh
653            .sessions
654            .get(&session)
655            .ok_or(DaemonError::UnknownSession(session))?;
656        if p.parked {
657            return Err(DaemonError::Config("session is parked; resume it first"));
658        }
659        Ok(p.node)
660    }
661
662    fn ensure_connected(&self, i: usize, guard: &mut Option<FleetHead>) -> Result<(), DaemonError> {
663        if guard.is_some() {
664            return Ok(());
665        }
666        let node = self.node(i);
667        let spec = &node.spec;
668        if spec.addr.starts_with(JOINED) {
669            let e = DaemonError::FleetNode { addr: spec.addr.clone(), why: "left; waiting for it to join again".into() };
670            self.mark_unhealthy(i, &e);
671            return Err(e);
672        }
673        match FleetHead::connect(&spec.addr, spec.auth.clone()) {
674            Ok(mut head) => {
675                head.keep_alive(self.liveness);
676                watch(&node, &mut head);
677                self.sync_meta(i, &head);
678                *guard = Some(head);
679                Ok(())
680            }
681            Err(e) => {
682                self.mark_unhealthy(i, &e);
683                Err(e)
684            }
685        }
686    }
687
688    /// A fresh connection knows none of the sessions placed over the one it replaced; this
689    /// places the session on it again from what the head kept.
690    fn rebind(&self, session: u64, head: &mut FleetHead) -> Result<(), DaemonError> {
691        if head.knows(session) {
692            return Ok(());
693        }
694        let p = self
695            .shared
696            .lock()
697            .expect("shared")
698            .sessions
699            .get(&session)
700            .cloned()
701            .ok_or(DaemonError::UnknownSession(session))?;
702        let epoch = head.assign(session, p.params, p.qos_class, &p.codec, self.lease)?;
703        if !p.tokens.is_empty() {
704            head.append(session, &p.tokens)?;
705        }
706        if let Some(p) = self.shared.lock().expect("shared").sessions.get_mut(&session) {
707            p.epoch = epoch;
708        }
709        Ok(())
710    }
711
712    /// Rebinds the session on the slot's connection, dropping the connection only when the link
713    /// itself failed.
714    fn rebind_on(&self, session: u64, guard: &mut Option<FleetHead>) -> Result<(), DaemonError> {
715        let r = self.rebind(session, guard.as_mut().expect("connected"));
716        if let Err(e) = &r {
717            if is_fatal_node(e) {
718                *guard = None;
719            }
720        }
721        r
722    }
723
724    pub fn advertised_context(&self, model: &str) -> Option<u64> {
725        let sh = self.shared.lock().expect("shared");
726        (0..self.node_count())
727            .filter(|&i| sh.meta[i].up() && Self::serves(&sh, i, model))
728            .map(|i| sh.meta[i].max_context_tokens)
729            .filter(|&c| c > 0)
730            .max()
731    }
732
733    fn serves(sh: &Shared, i: usize, model: &str) -> bool {
734        !sh.meta[i].learned || sh.meta[i].models.iter().any(|m| m == model)
735    }
736
737    fn holds(sh: &Shared, i: usize, len: u64) -> bool {
738        let max = sh.meta[i].max_context_tokens;
739        max == 0 || len <= max
740    }
741
742    fn length_refusal(&self, cand: &[usize], model: &str, len: u64) -> Option<DaemonError> {
743        let sh = self.shared.lock().expect("shared");
744        let eligible: Vec<usize> =
745            cand.iter().copied().filter(|&i| Self::serves(&sh, i, model)).collect();
746        if eligible.is_empty() || eligible.iter().any(|&i| Self::holds(&sh, i, len)) {
747            return None;
748        }
749        Some(DaemonError::StreamTooLong {
750            len,
751            max: eligible.iter().map(|&i| sh.meta[i].max_context_tokens).max().unwrap_or(0),
752        })
753    }
754
755    fn sync_meta(&self, i: usize, head: &FleetHead) {
756        let mut sh = self.shared.lock().expect("shared");
757        let cap = &head.node().capacity;
758        let theirs = head.tokenizer();
759        let (models, refused): (Vec<String>, Vec<String>) = {
760            let ours = self.tokenizers.read().expect("tokenizers");
761            head.node().loadable_models.iter().cloned().partition(|m| {
762                ours.get(m).is_none_or(|&d| d == 0 || theirs == 0 || d == theirs)
763            })
764        };
765        if !refused.is_empty() && sh.meta[i].tokenizer_refused != refused {
766            tracing::warn!(
767                "fleet: node {} tokenizes {} otherwise than this head does, so nothing for it is placed there; \
768                 serve the same build of the model on both",
769                self.label(&sh, i),
770                refused.join(", ")
771            );
772        }
773        sh.meta[i].tokenizer_refused = refused;
774        sh.meta[i].models = models;
775        sh.meta[i].max_context_tokens = cap.max_context_tokens;
776        sh.meta[i].identity = head.node().node_identity.clone();
777        sh.meta[i].learned = true;
778        sh.meta[i].load = Self::load_from(cap);
779        let node = self.node(i);
780        if self.conns_by_lanes && !node.spec.addr.starts_with(JOINED) {
781            let (lanes, pool) = (cap.max_lanes.max(1) as usize, node.conns.len());
782            let was = node.open.swap(lanes.min(pool), std::sync::atomic::Ordering::Relaxed);
783            if lanes > pool && was != pool {
784                tracing::warn!(
785                    "fleet: node {} has {lanes} lanes; the head opens at most {pool} connections to it",
786                    self.label(&sh, i)
787                );
788            }
789        }
790    }
791
792    fn load_from(cap: &superfluid_proto::handshake::CapacitySummary) -> NodeLoad {
793        NodeLoad {
794            kv_used: cap.kv_blocks_used,
795            kv_total: cap.kv_blocks_total,
796            lanes_active: cap.lanes_active,
797            queue_depth: cap.queue_depth,
798            decode_tokens_per_s: cap.decode_tokens_per_s,
799            prefill_tokens_per_s: cap.prefill_tokens_per_s,
800            at: Some(std::time::Instant::now()),
801        }
802    }
803
804    fn replacement_shape(tokens: &[u32], expected_out: u64) -> RequestShape {
805        RequestShape {
806            prompt_tokens: tokens.len() as u64,
807            expected_out,
808            warm: None,
809        }
810    }
811
812    fn candidates_claiming(
813        &self,
814        model: &str,
815        claim_for: Option<u64>,
816        shape: RequestShape,
817    ) -> Vec<usize> {
818        let mut sh = self.shared.lock().expect("shared");
819        let mut load = vec![0usize; self.node_count()];
820        for p in sh.sessions.values() {
821            if !p.parked {
822                load[p.node] += 1;
823            }
824        }
825        let mut cand: Vec<usize> = (0..self.node_count())
826            .filter(|&i| sh.meta[i].up() && Self::serves(&sh, i, model))
827            .collect();
828        match self.policy {
829            PlacementPolicy::LoadAware => {
830                let fits: Vec<usize> = cand
831                    .iter()
832                    .copied()
833                    .filter(|&i| Self::holds(&sh, i, shape.prompt_tokens))
834                    .collect();
835                let judged: &[usize] = if fits.is_empty() { &cand } else { &fits };
836                let hot: Vec<usize> = judged
837                    .iter()
838                    .copied()
839                    .filter(|&i| {
840                        sh.meta[i].load.pool_pct().is_some_and(|p| p >= self.pool_high_pct as u64)
841                    })
842                    .collect();
843                if hot.len() < judged.len() {
844                    cand.retain(|i| !hot.contains(i));
845                }
846                let inflight = sh.inflight.clone();
847                let mean = |rate: fn(&NodeLoad) -> u32| {
848                    let known: Vec<u64> =
849                        cand.iter().map(|&i| rate(&sh.meta[i].load) as u64).filter(|&r| r > 0).collect();
850                    known.iter().sum::<u64>() / (known.len() as u64).max(1)
851                };
852                let typical = (mean(|l| l.decode_tokens_per_s), mean(|l| l.prefill_tokens_per_s));
853                cand.sort_by_key(|&i| {
854                    (
855                        sh.meta[i].load.eta_ms(inflight[i], shape.prompt_tokens, shape.expected_out, typical),
856                        load[i],
857                    )
858                });
859            }
860            PlacementPolicy::LeastLoaded => {
861                let inflight = sh.inflight.clone();
862                cand.sort_by_key(|&i| load[i] + inflight[i] as usize);
863            }
864            PlacementPolicy::RoundRobin => {
865                let n = self.node_count().max(1);
866                let c = sh.rr_cursor % n;
867                cand.sort_by_key(|&i| (i + n - c) % n);
868                sh.rr_cursor = sh.rr_cursor.wrapping_add(1);
869            }
870        }
871        if let Some(w) = shape.warm.filter(|_| self.policy != PlacementPolicy::RoundRobin) {
872            if let Some(at) = cand.iter().position(|&i| i == w) {
873                let lanes = self.node(w).open.load(Ordering::Relaxed).max(1);
874                if (sh.inflight[w] as usize) < lanes {
875                    let w = cand.remove(at);
876                    cand.insert(0, w);
877                }
878            }
879        }
880        if let (Some(session), Some(&winner)) = (claim_for, cand.first()) {
881            Self::claim_locked(&mut sh, winner, session);
882        }
883        cand
884    }
885
886    /// The slot's connection, dropped first if the node closed it while it sat idle: a write
887    /// into a closed socket can look like it succeeded.
888    fn checkout_live(node_conn: &NodeConn, slot: usize) -> std::sync::MutexGuard<'_, Option<FleetHead>> {
889        Self::checkout_live_by(node_conn, slot, None).expect("no deadline to pass")
890    }
891
892    /// As [`Self::checkout_live`], none once `deadline` passes with the slot still held.
893    fn checkout_live_by(
894        node_conn: &NodeConn,
895        slot: usize,
896        deadline: Option<std::time::Instant>,
897    ) -> Option<std::sync::MutexGuard<'_, Option<FleetHead>>> {
898        let mut guard = node_conn.checkout_at_by(slot, deadline)?;
899        if guard.as_mut().is_some_and(|h| h.drain_status().is_err()) {
900            *guard = None;
901        }
902        Some(guard)
903    }
904
905    fn slot_of(&self, session: u64) -> usize {
906        self.shared
907            .lock()
908            .expect("shared")
909            .sessions
910            .get(&session)
911            .map(|p| p.slot)
912            .unwrap_or(0)
913    }
914
915    fn claim_locked(sh: &mut Shared, node: usize, session: u64) {
916        if let Some(prev) = sh.claims.insert(session, node) {
917            if prev == node {
918                return;
919            }
920            if let Some(n) = sh.inflight.get_mut(prev) {
921                *n = n.saturating_sub(1);
922            }
923        }
924        if let Some(n) = sh.inflight.get_mut(node) {
925            *n = n.saturating_add(1);
926        }
927    }
928
929    #[allow(clippy::too_many_arguments)]
930    pub fn place(
931        &self,
932        session: u64,
933        params: GenParams,
934        qos_class: u8,
935        codec: &str,
936        model: &str,
937        tokens: &[u32],
938        expected_out: u64,
939    ) -> Result<usize, DaemonError> {
940        let placed = self.place_by(session, params, qos_class, codec, model, tokens, expected_out, None)?;
941        Ok(placed.expect("no deadline to pass"))
942    }
943
944    /// As [`Self::place`], for a generation that must start by `deadline`: none once it passes
945    /// before the session is placed (every connection to a node serving the model held by
946    /// another generation meanwhile), rather than placed for a generation already too late.
947    #[allow(clippy::too_many_arguments)]
948    pub fn place_by(
949        &self,
950        session: u64,
951        params: GenParams,
952        qos_class: u8,
953        codec: &str,
954        model: &str,
955        tokens: &[u32],
956        expected_out: u64,
957        deadline: Option<std::time::Instant>,
958    ) -> Result<Option<usize>, DaemonError> {
959        let warm = self.warm_node(model, tokens);
960        let cand = self.candidates_claiming(
961            model,
962            Some(session),
963            RequestShape {
964                prompt_tokens: tokens.len() as u64,
965                expected_out,
966                warm,
967            },
968        );
969        let release_on_error = |e: DaemonError| {
970            InFlight::release(&self.shared, session);
971            e
972        };
973        if cand.is_empty() {
974            return Err(release_on_error(DaemonError::NoNode(
975                "no healthy node can serve the model",
976            )));
977        }
978        let len = tokens.len() as u64;
979        if let Some(e) = self.length_refusal(&cand, model, len) {
980            return Err(release_on_error(e));
981        }
982        let late = || deadline.is_some_and(|at| std::time::Instant::now() >= at);
983        let mut last_err = None;
984        for &i in &cand {
985            if late() {
986                break;
987            }
988            match self.try_place_by(i, session, params, qos_class, codec, model, tokens, deadline) {
989                Ok(true) => {
990                    tracing::info!(
991                        "fleet: session {session} ({} prompt tokens) placed on {}",
992                        tokens.len(),
993                        self.identity(i)
994                    );
995                    return Ok(Some(i));
996                }
997                Ok(false) => continue,
998                Err(e) => {
999                    self.mark_unhealthy(i, &e);
1000                    self.node(i).reset();
1001                    last_err = Some(e);
1002                }
1003            }
1004        }
1005        if late() {
1006            InFlight::release(&self.shared, session);
1007            return Ok(None);
1008        }
1009        if let Some(e) = self.length_refusal(&cand, model, len) {
1010            return Err(release_on_error(e));
1011        }
1012        Err(release_on_error(last_err.unwrap_or(DaemonError::NoNode(
1013            "no reachable node serves the model",
1014        ))))
1015    }
1016
1017    #[allow(clippy::too_many_arguments)]
1018    fn try_place_on(
1019        &self,
1020        i: usize,
1021        session: u64,
1022        params: GenParams,
1023        qos_class: u8,
1024        codec: &str,
1025        model: &str,
1026        tokens: &[u32],
1027    ) -> Result<bool, DaemonError> {
1028        self.try_place_by(i, session, params, qos_class, codec, model, tokens, None)
1029    }
1030
1031    /// As [`Self::try_place_on`], not placed once `deadline` passes while every connection to
1032    /// the node is held.
1033    #[allow(clippy::too_many_arguments)]
1034    fn try_place_by(
1035        &self,
1036        i: usize,
1037        session: u64,
1038        params: GenParams,
1039        qos_class: u8,
1040        codec: &str,
1041        model: &str,
1042        tokens: &[u32],
1043        deadline: Option<std::time::Instant>,
1044    ) -> Result<bool, DaemonError> {
1045        let node = self.node(i);
1046        let Some((slot, mut guard)) = node.checkout_free(deadline) else {
1047            return Ok(false);
1048        };
1049        if let Some(h) = guard.as_mut() {
1050            if h.drain_status().is_err() {
1051                *guard = None;
1052            }
1053        }
1054        self.ensure_connected(i, &mut guard)?;
1055        self.flush_forgets(i, slot, guard.as_mut().expect("connected"));
1056        self.sync_meta(i, guard.as_ref().expect("connected"));
1057        {
1058            let sh = self.shared.lock().expect("shared");
1059            if !Self::serves(&sh, i, model) {
1060                return Ok(false);
1061            }
1062            if !Self::holds(&sh, i, tokens.len() as u64) {
1063                return Ok(false);
1064            }
1065        }
1066        let head = guard.as_mut().expect("connected");
1067        let epoch = match head.assign(session, params, qos_class, codec, self.lease) {
1068            Ok(epoch) => epoch,
1069            Err(e) => {
1070                *guard = None;
1071                return Err(e);
1072            }
1073        };
1074        if !tokens.is_empty() {
1075            if let Err(e) = head.append(session, tokens) {
1076                *guard = None;
1077                return Err(e);
1078            }
1079        }
1080        InFlight::claim(&self.shared, i, session);
1081        self.shared.lock().expect("shared").sessions.insert(
1082            session,
1083            Placed {
1084                node: i,
1085                params,
1086                qos_class,
1087                codec: codec.to_string(),
1088                model: model.to_string(),
1089                tokens: tokens.to_vec(),
1090                slot,
1091                epoch,
1092                parked: false,
1093            },
1094        );
1095        Ok(true)
1096    }
1097
1098    pub fn append(&self, session: u64, tokens: &[u32]) -> Result<(), DaemonError> {
1099        let node = self.active_node(session)?;
1100        let len = tokens.len() as u64;
1101        let fits = {
1102            let sh = self.shared.lock().expect("shared");
1103            Self::holds(&sh, node, len)
1104        };
1105        if !fits {
1106            return self.re_place_grown(session, node, tokens);
1107        }
1108        {
1109            let node_conn = self.node(node);
1110            let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
1111            self.ensure_connected(node, &mut guard)?;
1112            self.rebind_on(session, &mut guard)?;
1113            let head = guard.as_mut().expect("connected");
1114            if let Err(e) = head.append(session, tokens) {
1115                *guard = None;
1116                return Err(e);
1117            }
1118            self.sync_meta(node, head);
1119        }
1120        if let Some(p) = self.shared.lock().expect("shared").sessions.get_mut(&session) {
1121            p.tokens = tokens.to_vec();
1122        }
1123        Ok(())
1124    }
1125
1126    fn re_place_grown(
1127        &self,
1128        session: u64,
1129        from: usize,
1130        tokens: &[u32],
1131    ) -> Result<(), DaemonError> {
1132        let placed = {
1133            let sh = self.shared.lock().expect("shared");
1134            sh.sessions.get(&session).cloned().ok_or(DaemonError::UnknownSession(session))?
1135        };
1136        let cand: Vec<usize> = self
1137            .candidates_claiming(
1138                &placed.model,
1139                Some(session),
1140                Self::replacement_shape(tokens, 0),
1141            )
1142            .into_iter()
1143            .filter(|&i| i != from)
1144            .collect();
1145        let len = tokens.len() as u64;
1146        if let Some(e) = self.length_refusal(&cand, &placed.model, len) {
1147            return Err(e);
1148        }
1149        for &i in &cand {
1150            match self.try_place_on(
1151                i,
1152                session,
1153                placed.params,
1154                placed.qos_class,
1155                &placed.codec,
1156                &placed.model,
1157                tokens,
1158            ) {
1159                Ok(true) => return Ok(()),
1160                Ok(false) => continue,
1161                Err(e) => {
1162                    self.mark_unhealthy(i, &e);
1163                    self.node(i).reset();
1164                }
1165            }
1166        }
1167        if let Some(e) = self.length_refusal(&cand, &placed.model, len) {
1168            return Err(e);
1169        }
1170        Err(DaemonError::Config(
1171            "no reachable node can hold the session's grown context",
1172        ))
1173    }
1174
1175    pub fn generate(
1176        &self,
1177        session: u64,
1178        max_new_tokens: u64,
1179        max_wall_ms: u64,
1180    ) -> Result<FleetGeneration, DaemonError> {
1181        let node = self.active_node(session)?;
1182
1183        match self.generate_on(node, session, max_new_tokens, max_wall_ms) {
1184            Ok(g) => return Ok(g),
1185            Err(e) if !is_fatal_node(&e) => return Err(e),
1186            Err(e) => self.mark_unhealthy(node, &e),
1187        }
1188        self.node(node).reset();
1189        let placed = self
1190            .shared
1191            .lock()
1192            .expect("shared")
1193            .sessions
1194            .get(&session)
1195            .cloned()
1196            .expect("placed session");
1197        let cand: Vec<usize> = self
1198            .candidates_claiming(
1199                &placed.model,
1200                Some(session),
1201                Self::replacement_shape(&placed.tokens, max_new_tokens),
1202            )
1203            .into_iter()
1204            .filter(|&i| i != node)
1205            .collect();
1206        for i in cand {
1207            match self.try_place_on(
1208                i,
1209                session,
1210                placed.params,
1211                placed.qos_class,
1212                &placed.codec,
1213                &placed.model,
1214                &placed.tokens,
1215            ) {
1216                Ok(true) => {}
1217                Ok(false) => continue,
1218                Err(e) => {
1219                    self.mark_unhealthy(i, &e);
1220                    self.node(i).reset();
1221                    continue;
1222                }
1223            }
1224            tracing::warn!("fleet: session {session} moved to {}", self.identity(i));
1225            match self.generate_on(i, session, max_new_tokens, max_wall_ms) {
1226                Ok(g) => return Ok(g),
1227                Err(e) => self.mark_unhealthy(i, &e),
1228            }
1229            self.node(i).reset();
1230        }
1231        Err(DaemonError::Generation(
1232            "generation failed and no healthy node could take over the session".into(),
1233        ))
1234    }
1235
1236    fn map_refusal(
1237        &self,
1238        node: usize,
1239        session: u64,
1240        r: Result<FleetGeneration, DaemonError>,
1241    ) -> Result<FleetGeneration, DaemonError> {
1242        let g = r?;
1243        if g.finish == crate::nodeagent::FINISH_DECLINED {
1244            return Err(DaemonError::Generation(format!(
1245                "node {} declined session {session} (stale epoch or replayed command)",
1246                self.shared.lock().expect("shared").meta[node].identity
1247            )));
1248        }
1249        if g.finish != crate::nodeagent::FINISH_REFUSED {
1250            return Ok(g);
1251        }
1252        let sh = self.shared.lock().expect("shared");
1253        let len = sh.sessions.get(&session).map(|p| p.tokens.len() as u64).unwrap_or(0);
1254        Err(DaemonError::StreamTooLong {
1255            len,
1256            max: sh.meta[node].max_context_tokens,
1257        })
1258    }
1259
1260    fn generate_on(
1261        &self,
1262        node: usize,
1263        session: u64,
1264        max_new_tokens: u64,
1265        max_wall_ms: u64,
1266    ) -> Result<FleetGeneration, DaemonError> {
1267        let _inflight = InFlight::guard(&self.shared, node, session);
1268        let node_conn = self.node(node);
1269        let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
1270        self.ensure_connected(node, &mut guard)?;
1271        self.rebind_on(session, &mut guard)?;
1272        let head = guard.as_mut().expect("connected");
1273        let r = head.generate(session, max_new_tokens, max_wall_ms);
1274        if let Ok(g) = &r {
1275            node_conn.decoded.fetch_add(g.tokens.len() as u64, Ordering::Relaxed);
1276        }
1277        if let Err(e) = &r {
1278            if is_fatal_node(e) {
1279                *guard = None;
1280            }
1281        }
1282        if let Some(head) = guard.as_ref() {
1283            self.sync_meta(node, head);
1284        }
1285        drop(guard);
1286        self.map_refusal(node, session, r)
1287    }
1288
1289    pub fn generate_streaming(
1290        &self,
1291        session: u64,
1292        max_new_tokens: u64,
1293        max_wall_ms: u64,
1294        on_tokens: impl FnMut(&[u32]),
1295    ) -> Result<FleetGeneration, DaemonError> {
1296        self.generate_streaming_with(session, max_new_tokens, max_wall_ms, None, &mut |_| {}, on_tokens)
1297    }
1298
1299    /// As [`Self::generate_streaming`], with what the generation asks beyond its sampling, sent
1300    /// to whichever node runs it, and `on_logprobs` told the log-probabilities it asked for.
1301    pub fn generate_streaming_with(
1302        &self,
1303        session: u64,
1304        max_new_tokens: u64,
1305        max_wall_ms: u64,
1306        opts: Option<&crate::fleet::Ask<'_>>,
1307        on_logprobs: &mut dyn FnMut(&[superfluid_proto::linkf::TokenLogprobMsg]),
1308        on_tokens: impl FnMut(&[u32]),
1309    ) -> Result<FleetGeneration, DaemonError> {
1310        self.generate_streaming_by(session, max_new_tokens, max_wall_ms, None, opts, on_logprobs, on_tokens)
1311    }
1312
1313    /// As [`Self::generate_streaming_with`], for a generation that must start by `deadline`: the
1314    /// wait for the session's connection counts against it, one that passes meanwhile ends the
1315    /// generation unstarted (expired, no tokens), and the node is given only the time left.
1316    #[allow(clippy::too_many_arguments)]
1317    pub fn generate_streaming_by(
1318        &self,
1319        session: u64,
1320        max_new_tokens: u64,
1321        max_wall_ms: u64,
1322        deadline: Option<std::time::Instant>,
1323        opts: Option<&crate::fleet::Ask<'_>>,
1324        on_logprobs: &mut dyn FnMut(&[superfluid_proto::linkf::TokenLogprobMsg]),
1325        mut on_tokens: impl FnMut(&[u32]),
1326    ) -> Result<FleetGeneration, DaemonError> {
1327        let (node, flag) = {
1328            let mut sh = self.shared.lock().expect("shared");
1329            let p = sh
1330                .sessions
1331                .get(&session)
1332                .ok_or(DaemonError::UnknownSession(session))?;
1333            if p.parked {
1334                return Err(DaemonError::Config("session is parked; resume it first"));
1335            }
1336            let node = p.node;
1337            let flag = sh
1338                .cancels
1339                .entry(session)
1340                .or_insert_with(|| Arc::new(AtomicBool::new(false)))
1341                .clone();
1342            flag.store(false, Ordering::Relaxed);
1343            (node, flag)
1344        };
1345
1346        let mut all_tokens: Vec<u32> = Vec::new();
1347
1348        match self.generate_streaming_on(node, session, max_new_tokens, max_wall_ms, deadline, &flag, opts, on_logprobs, |b| {
1349            all_tokens.extend_from_slice(b);
1350            on_tokens(b);
1351        }) {
1352            Ok(g) => return Ok(g),
1353            Err(e) if !is_fatal_node(&e) => return Err(e),
1354            Err(e) => self.mark_unhealthy(node, &e),
1355        }
1356
1357        let placed = self
1358            .shared
1359            .lock()
1360            .expect("shared")
1361            .sessions
1362            .get(&session)
1363            .cloned()
1364            .expect("placed session");
1365        let input = placed.tokens.clone();
1366        let mut tried = vec![node];
1367        loop {
1368            let remaining = max_new_tokens.saturating_sub(all_tokens.len() as u64);
1369            if remaining == 0 {
1370                return Ok(FleetGeneration {
1371                    tokens: all_tokens,
1372                    finish: superfluid_abi::finish::LENGTH,
1373                    expired: false,
1374                });
1375            }
1376            if deadline.is_some_and(|at| std::time::Instant::now() >= at) {
1377                return Ok(FleetGeneration {
1378                    tokens: all_tokens,
1379                    finish: crate::nodeagent::FINISH_EXPIRED,
1380                    expired: true,
1381                });
1382            }
1383            let cand: Vec<usize> = self
1384                .candidates_claiming(
1385                    &placed.model,
1386                    Some(session),
1387                    Self::replacement_shape(&input, remaining),
1388                )
1389                .into_iter()
1390                .filter(|i| !tried.contains(i))
1391                .collect();
1392            if cand.is_empty() {
1393                return Err(DaemonError::Generation(
1394                    "streaming generation failed and no healthy node could take over".into(),
1395                ));
1396            }
1397            let i = cand[0];
1398            tried.push(i);
1399            let mut ctx = input.clone();
1400            ctx.extend_from_slice(&all_tokens);
1401            match self.try_place_by(
1402                i,
1403                session,
1404                placed.params,
1405                placed.qos_class,
1406                &placed.codec,
1407                &placed.model,
1408                &ctx,
1409                deadline,
1410            ) {
1411                Ok(true) => {}
1412                Ok(false) => continue,
1413                Err(e) => {
1414                    self.mark_unhealthy(i, &e);
1415                    self.node(i).reset();
1416                    continue;
1417                }
1418            }
1419            tracing::warn!(
1420                "fleet: session {session} moved to {} after {} streamed tokens",
1421                self.identity(i),
1422                all_tokens.len()
1423            );
1424            match self.generate_streaming_on(i, session, remaining, max_wall_ms, deadline, &flag, opts, on_logprobs, |b| {
1425                all_tokens.extend_from_slice(b);
1426                on_tokens(b);
1427            }) {
1428                Ok(g) => {
1429                    return Ok(FleetGeneration {
1430                        tokens: all_tokens,
1431                        finish: g.finish,
1432                        expired: g.expired,
1433                    })
1434                }
1435                Err(e) if !is_fatal_node(&e) => return Err(e),
1436                Err(e) => self.mark_unhealthy(i, &e),
1437            }
1438        }
1439    }
1440
1441    #[allow(clippy::too_many_arguments)]
1442    fn generate_streaming_on(
1443        &self,
1444        node: usize,
1445        session: u64,
1446        max_new_tokens: u64,
1447        max_wall_ms: u64,
1448        deadline: Option<std::time::Instant>,
1449        flag: &AtomicBool,
1450        opts: Option<&crate::fleet::Ask<'_>>,
1451        on_logprobs: &mut dyn FnMut(&[superfluid_proto::linkf::TokenLogprobMsg]),
1452        mut on_tokens: impl FnMut(&[u32]),
1453    ) -> Result<FleetGeneration, DaemonError> {
1454        let _inflight = InFlight::guard(&self.shared, node, session);
1455        let node_conn = self.node(node);
1456        // Another session placed on the same connection may hold it now: the wait for it counts
1457        // against the deadline, and the node is given what is left once it is had.
1458        let unstarted = || FleetGeneration { tokens: Vec::new(), finish: crate::nodeagent::FINISH_EXPIRED, expired: true };
1459        let Some(mut guard) = Self::checkout_live_by(&node_conn, self.slot_of(session), deadline) else {
1460            return Ok(unstarted());
1461        };
1462        let max_wall_ms = match deadline.map(|at| at.saturating_duration_since(std::time::Instant::now())) {
1463            Some(left) if left.is_zero() => return Ok(unstarted()),
1464            Some(left) => max_wall_ms.min(left.as_millis().max(1) as u64),
1465            None => max_wall_ms,
1466        };
1467        self.ensure_connected(node, &mut guard)?;
1468        self.rebind_on(session, &mut guard)?;
1469        let head = guard.as_mut().expect("connected");
1470        let decoded = &node_conn.decoded;
1471        let r = head.generate_streaming_with(session, max_new_tokens, max_wall_ms, flag, opts, on_logprobs, |b: &[u32]| {
1472            decoded.fetch_add(b.len() as u64, Ordering::Relaxed);
1473            on_tokens(b);
1474        });
1475        if let Err(e) = &r {
1476            if is_fatal_node(e) {
1477                *guard = None;
1478            }
1479        }
1480        if let Some(head) = guard.as_ref() {
1481            self.sync_meta(node, head);
1482        }
1483        drop(guard);
1484        self.map_refusal(node, session, r)
1485    }
1486
1487    /// Runs a state operation on one node serving `model`, the next one tried when a node
1488    /// fails.
1489    pub fn state_sync(
1490        &self,
1491        model: &str,
1492        req: superfluid_proto::linkw::StateSyncReqMsg,
1493        blobs: &[crate::fleet::Blob],
1494    ) -> Result<superfluid_proto::linkw::StateSyncOkMsg, DaemonError> {
1495        self.on_a_node(model, |head| head.state_sync(req.clone(), blobs))
1496    }
1497
1498    /// [`FleetManager::state_sync`] for a streamed transcription, each segment told as the node
1499    /// has it. Once one has been told, a node lost after it fails the call rather than starting
1500    /// over on another, which would tell those segments twice.
1501    pub fn state_sync_streaming(
1502        &self,
1503        model: &str,
1504        req: superfluid_proto::linkw::StateSyncReqMsg,
1505        blobs: &[crate::fleet::Blob],
1506        on_segment: &mut dyn FnMut(superfluid_proto::linkw::TranscribeSegmentMsg),
1507    ) -> Result<superfluid_proto::linkw::StateSyncOkMsg, DaemonError> {
1508        let mut told = false;
1509        self.on_a_node(model, |head| {
1510            let answer = head.state_sync_streaming(req.clone(), blobs, &mut |seg| {
1511                told = true;
1512                on_segment(seg);
1513            });
1514            match answer {
1515                Err(e) if told && is_fatal_node(&e) => Err(DaemonError::Generation(format!("the node transcribing was lost: {e}"))),
1516                answer => answer,
1517            }
1518        })
1519    }
1520
1521    /// Runs a state operation on every node up that serves `model`, as a LoRA must be on each
1522    /// node a request can land on; the answer is the last node's.
1523    pub fn state_sync_all(
1524        &self,
1525        model: &str,
1526        req: superfluid_proto::linkw::StateSyncReqMsg,
1527    ) -> Result<superfluid_proto::linkw::StateSyncOkMsg, DaemonError> {
1528        let cand = self.candidates_claiming(model, None, RequestShape::default());
1529        let mut answer = Err(DaemonError::NoNode("no healthy node can serve the model"));
1530        for i in cand {
1531            answer = Ok(self.state_sync_on(i, req.clone(), &[])?);
1532        }
1533        answer
1534    }
1535
1536    fn state_sync_on(
1537        &self,
1538        i: usize,
1539        req: superfluid_proto::linkw::StateSyncReqMsg,
1540        blobs: &[crate::fleet::Blob],
1541    ) -> Result<superfluid_proto::linkw::StateSyncOkMsg, DaemonError> {
1542        self.on_node(i, |head| head.state_sync(req, blobs))
1543    }
1544
1545    /// Runs `call` on an idle connection to node `i`, connecting it first if it has to; a node
1546    /// whose link fails is down.
1547    fn on_node<R>(&self, i: usize, call: impl FnOnce(&mut FleetHead) -> Result<R, DaemonError>) -> Result<R, DaemonError> {
1548        let node = self.node(i);
1549        let mut guard = node.checkout_idle();
1550        if guard.as_mut().is_some_and(|h| h.drain_status().is_err()) {
1551            *guard = None;
1552        }
1553        self.ensure_connected(i, &mut guard)?;
1554        let r = call(guard.as_mut().expect("connected"));
1555        if let Err(e) = &r {
1556            if is_fatal_node(e) {
1557                *guard = None;
1558                self.mark_unhealthy(i, e);
1559            }
1560        }
1561        r
1562    }
1563
1564    /// Runs `call` on one node serving `model`, the next tried when a node fails.
1565    fn on_a_node<R>(&self, model: &str, mut call: impl FnMut(&mut FleetHead) -> Result<R, DaemonError>) -> Result<R, DaemonError> {
1566        let cand = self.candidates_claiming(model, None, RequestShape::default());
1567        let mut last = None;
1568        for i in cand {
1569            match self.on_node(i, &mut call) {
1570                Ok(r) => return Ok(r),
1571                Err(e) if is_fatal_node(&e) => last = Some(e),
1572                Err(e) => return Err(e),
1573            }
1574        }
1575        Err(last.unwrap_or(DaemonError::NoNode("no healthy node can serve the model")))
1576    }
1577
1578    /// The tokenizer of a node serving `model`, for a head with no copy of it; encoding asks a
1579    /// node serving the model each time.
1580    pub fn remote_tokenizer(self: &Arc<Self>, model: &str) -> Result<crate::fleet::RemoteTokenizer, DaemonError> {
1581        let export = self.on_a_node(model, |head| head.tokenizer_export())?;
1582        if export.vocab.is_empty() {
1583            return Err(DaemonError::Generation(format!(
1584                "no node serving {model} shares a tokenizer; give the head --model a path to the model's files"
1585            )));
1586        }
1587        Ok(crate::fleet::RemoteTokenizer::new(Arc::clone(self), model.to_string(), export))
1588    }
1589
1590    /// Text a node serving `model` encodes.
1591    pub fn tokenize(&self, model: &str, text: &superfluid_proto::linkf::TokenizeMsg) -> Result<Option<Vec<u32>>, DaemonError> {
1592        self.on_a_node(model, |head| head.tokenize(text.clone()))
1593    }
1594
1595    pub fn cancel(&self, session: u64) -> Result<(), DaemonError> {
1596        let sh = self.shared.lock().expect("shared");
1597        if !sh.sessions.contains_key(&session) {
1598            return Err(DaemonError::UnknownSession(session));
1599        }
1600        if let Some(flag) = sh.cancels.get(&session) {
1601            flag.store(true, Ordering::Relaxed);
1602        }
1603        Ok(())
1604    }
1605
1606    pub fn park(&self, session: u64) -> Result<(), DaemonError> {
1607        let node = {
1608            let mut sh = self.shared.lock().expect("shared");
1609            let p = sh
1610                .sessions
1611                .get_mut(&session)
1612                .ok_or(DaemonError::UnknownSession(session))?;
1613            if p.parked {
1614                return Ok(());
1615            }
1616            p.parked = true;
1617            (p.node, p.slot, p.epoch)
1618        };
1619        let (node, slot, epoch) = node;
1620        let node_conn = self.node(node);
1621        let mut guard = Self::checkout_live(&node_conn, slot);
1622        if self.ensure_connected(node, &mut guard).is_ok() {
1623            if let Err(e) = guard.as_mut().expect("connected").revoke(session, epoch) {
1624                *guard = None;
1625                self.mark_unhealthy(node, &e);
1626            }
1627        }
1628        Ok(())
1629    }
1630
1631    pub fn resume(&self, session: u64) -> Result<usize, DaemonError> {
1632        let placed = {
1633            let sh = self.shared.lock().expect("shared");
1634            let p = sh
1635                .sessions
1636                .get(&session)
1637                .ok_or(DaemonError::UnknownSession(session))?;
1638            if !p.parked {
1639                return Err(DaemonError::Config("session is not parked"));
1640            }
1641            p.clone()
1642        };
1643        let cand = self.candidates_claiming(
1644            &placed.model,
1645            Some(session),
1646            Self::replacement_shape(&placed.tokens, 0),
1647        );
1648        if cand.is_empty() {
1649            InFlight::release(&self.shared, session);
1650            return Err(DaemonError::NoNode("no healthy node can serve the model"));
1651        }
1652        let mut last_err = None;
1653        for i in cand {
1654            match self.try_place_on(
1655                i,
1656                session,
1657                placed.params,
1658                placed.qos_class,
1659                &placed.codec,
1660                &placed.model,
1661                &placed.tokens,
1662            ) {
1663                Ok(true) => return Ok(i),
1664                Ok(false) => continue,
1665                Err(e) => {
1666                    self.mark_unhealthy(i, &e);
1667                    self.node(i).reset();
1668                    last_err = Some(e);
1669                }
1670            }
1671        }
1672        Err(last_err.unwrap_or(DaemonError::NoNode("no reachable node serves the model")))
1673    }
1674
1675    /// Ends a session the caller is done with: the head drops its record, and the node the
1676    /// context it kept (now if its connection is free, else the next time it is).
1677    pub fn finish(&self, session: u64) {
1678        let placed = {
1679            let mut sh = self.shared.lock().expect("shared");
1680            sh.cancels.remove(&session);
1681            sh.sessions.remove(&session)
1682        };
1683        InFlight::release(&self.shared, session);
1684        let Some(p) = placed else { return };
1685        let identity = self.shared.lock().expect("shared").meta[p.node].identity.clone();
1686        if !identity.is_empty() {
1687            self.warm.lock().expect("warm").ran(&p.model, &p.tokens, &identity);
1688        }
1689        self.shared.lock().expect("shared").forgets.push((p.node, p.slot, session, p.epoch));
1690        let node = self.node(p.node);
1691        let lock = node.conns[p.slot].try_lock();
1692        if let Ok(mut guard) = lock {
1693            if let Some(head) = guard.as_mut() {
1694                self.flush_forgets(p.node, p.slot, head);
1695            }
1696        }
1697    }
1698
1699    fn flush_forgets(&self, node: usize, slot: usize, head: &mut FleetHead) {
1700        let due: Vec<(u64, u64)> = {
1701            let mut sh = self.shared.lock().expect("shared");
1702            if sh.forgets.is_empty() {
1703                return;
1704            }
1705            let (due, keep) = sh.forgets.drain(..).partition(|&(n, s, _, _)| n == node && s == slot);
1706            sh.forgets = keep;
1707            due.into_iter().map(|(_, _, session, epoch)| (session, epoch)).collect()
1708        };
1709        for (session, epoch) in due {
1710            let _ = head.revoke(session, epoch);
1711        }
1712    }
1713
1714    /// Connects to every node once, so the head learns each one (or why it cannot) at start.
1715    pub fn probe(&self) -> Vec<Result<NodeReport, DaemonError>> {
1716        std::thread::scope(|scope| {
1717            let jobs: Vec<_> = (0..self.node_count())
1718                .map(|i| {
1719                    scope.spawn(move || {
1720                        let node = self.node(i);
1721                        let mut guard = node.checkout_at(0);
1722                        self.ensure_connected(i, &mut guard)?;
1723                        let node = guard.as_ref().expect("connected").node();
1724                        Ok(NodeReport {
1725                            addr: self.node(i).spec.addr.clone(),
1726                            identity: node.node_identity.clone(),
1727                            models: node.loadable_models.clone(),
1728                            max_context_tokens: node.capacity.max_context_tokens,
1729                            max_lanes: node.capacity.max_lanes,
1730                        })
1731                    })
1732                })
1733                .collect();
1734            jobs.into_iter().map(|j| j.join().expect("probe thread")).collect()
1735        })
1736    }
1737
1738    /// Reads every idle connection, which sends the node a heartbeat when one is due and
1739    /// answers its lease renewals; drops the ones a node closed or went silent on, and finishes
1740    /// deferred session ends. A connection in use is read by the call using it.
1741    pub fn keepalive(&self) {
1742        let nodes: Vec<Arc<NodeConn>> = self.nodes.read().expect("nodes").clone();
1743        for (i, node) in nodes.iter().enumerate() {
1744            let mut dropped = None;
1745            for (slot, conn) in node.conns.iter().enumerate() {
1746                let Ok(mut guard) = conn.try_lock() else { continue };
1747                let Some(head) = guard.as_mut() else { continue };
1748                self.flush_forgets(i, slot, head);
1749                match head.drain_status() {
1750                    Ok(()) => self.sync_meta(i, head),
1751                    Err(e) => {
1752                        *guard = None;
1753                        dropped = Some(e);
1754                    }
1755                }
1756            }
1757            if let Ok(mut guard) = node.control.try_lock() {
1758                if guard.as_mut().is_some_and(|h| h.drain_status().is_err()) {
1759                    *guard = None;
1760                }
1761            }
1762            // With its last connection gone the node is down until the background retry reaches
1763            // it again, which also learns what it serves now: a node that restarted under another
1764            // model name would otherwise be passed over for the name it had.
1765            if let Some(e) = dropped {
1766                if node.conns.iter().all(|c| c.try_lock().is_ok_and(|g| g.is_none())) {
1767                    self.mark_unhealthy(i, &e);
1768                }
1769            }
1770        }
1771    }
1772
1773    /// Tries every node that failed and is due a retry, in parallel and outside the connection
1774    /// slots; one that answers is placed on again. Requests never wait on a down node.
1775    pub fn retry_down(&self) {
1776        let due: Vec<usize> = {
1777            let mut sh = self.shared.lock().expect("shared");
1778            let now = std::time::Instant::now();
1779            let mut due = Vec::new();
1780            for (i, m) in sh.meta.iter_mut().enumerate() {
1781                if m.down_since.is_some() && m.last_retry.is_none_or(|t| now.duration_since(t) >= self.retry_every) {
1782                    m.last_retry = Some(now);
1783                    due.push(i);
1784                }
1785            }
1786            due
1787        };
1788        std::thread::scope(|scope| {
1789            for i in due {
1790                scope.spawn(move || {
1791                    let node = self.node(i);
1792                    let spec = &node.spec;
1793                    // A joined node comes back when it dials in again.
1794                    if spec.addr.starts_with(JOINED) {
1795                        return;
1796                    }
1797                    match FleetHead::connect(&spec.addr, spec.auth.clone()) {
1798                        Ok(mut head) => {
1799                            head.keep_alive(self.liveness);
1800                            watch(&node, &mut head);
1801                            self.sync_meta(i, &head);
1802                            self.mark_up(i);
1803                            if let Ok(mut slot) = node.conns[0].try_lock() {
1804                                if slot.is_none() {
1805                                    *slot = Some(head);
1806                                }
1807                            }
1808                        }
1809                        Err(e) => tracing::debug!("fleet: {e}"),
1810                    }
1811                });
1812            }
1813        });
1814    }
1815
1816    pub fn tend(&self) {
1817        self.keepalive();
1818        self.retry_down();
1819        self.warm.lock().expect("warm").save();
1820    }
1821
1822    /// The node, up and serving `model`, that last ran the longest prefix of `tokens`.
1823    fn warm_node(&self, model: &str, tokens: &[u32]) -> Option<usize> {
1824        let warm = self.warm.lock().expect("warm");
1825        let (identity, _) = warm.warmest(model, tokens)?;
1826        let sh = self.shared.lock().expect("shared");
1827        (0..sh.meta.len()).find(|&i| sh.meta[i].identity == identity && sh.meta[i].up() && Self::serves(&sh, i, model))
1828    }
1829
1830    pub fn spawn_keepalive(self: &Arc<Self>) {
1831        let mgr = Arc::downgrade(self);
1832        let tick = self.retry_every.min(self.liveness.heartbeat);
1833        std::thread::Builder::new()
1834            .name("fleet-keepalive".into())
1835            .spawn(move || loop {
1836                std::thread::sleep(tick);
1837                let Some(mgr) = mgr.upgrade() else { return };
1838                mgr.tend();
1839            })
1840            .expect("spawn the fleet keepalive");
1841    }
1842}
1843
1844/// Whether a slot holds a connection, for [`FleetManager::adopt`], which must not wait on one:
1845/// a call holds a slot's lock while it waits for the shared state adopt holds. A slot whose lock
1846/// is held is in use, so connected.
1847fn in_use(slot: &Mutex<Option<FleetHead>>) -> bool {
1848    slot.try_lock().map_or(true, |g| g.is_some())
1849}
1850
1851fn is_fatal_node(e: &DaemonError) -> bool {
1852    matches!(
1853        e,
1854        DaemonError::FleetNode { .. }
1855            | DaemonError::Fleet(superfluid_linkf::LinkFError::Closed)
1856            | DaemonError::Fleet(superfluid_linkf::LinkFError::TimedOut)
1857            | DaemonError::Fleet(superfluid_linkf::LinkFError::Io(_))
1858    )
1859}