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_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    /// `typical` stands in for a rate this node has not reported yet: the mean of the nodes
43    /// that have, so a node that has served nothing is neither shunned nor flooded.
44    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        // A rate is what the node does when busy, so it stays true while the node idles; only the
53        // load that piles up ahead of a request goes stale.
54        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    /// How many of `conns` are in use: the count asked for, or the node's lanes once learned.
78    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    /// Where a joined node dialed from, so a second machine with its name is told apart.
126    peer: Option<std::net::IpAddr>,
127    /// Set when the node failed; requests pass it over until the background retry connects.
128    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    /// Finished sessions to revoke on a node: (node, slot, session, epoch).
151    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
223/// 0: as many connections to a node as it has lanes, so each lane can be busy at once.
224pub const DEFAULT_CONNS_PER_NODE: usize = 0;
225
226const MAX_CONNS_PER_NODE: usize = 64;
227
228/// The address a joined node is listed under: it dialed in, so the head cannot dial it.
229const JOINED: &str = "joined:";
230
231/// How often the background retry tries a node that failed.
232pub const RETRY_DOWN_AFTER: std::time::Duration = std::time::Duration::from_secs(10);
233
234/// Under the node's idle timeout (120 s by default), so an idle head keeps its connections.
235pub 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    /// Takes in a node that dialed the head: one seen before, by identity, gets its slot
311    /// back; a new one gets a slot of its own. `true` for a new one.
312    /// Takes in a connection from a node that dialed the head. The node has a slot per lane it
313    /// advertised and fills one per connection it opens, so it runs as many generations at
314    /// once as it dialed; a node seen before, by identity, takes its slots back, unless it is
315    /// still connected from another address, which is a second machine with the same name.
316    /// Returns the node's index, whether it is new, and its open connections.
317    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                    // Listed as down until its connection is in place, so no request lands
344                    // on an empty slot meanwhile.
345                    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            // Listed as down only until this connection was in place: not a return.
369            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    /// The connections a node has open.
377    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    /// How often a node that failed is tried again in the background (default
399    /// [`RETRY_DOWN_AFTER`]).
400    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    /// A fresh connection knows none of the sessions placed over the one it replaced; this
473    /// places the session on it again from what the head kept.
474    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    /// Rebinds the session on the slot's connection, dropping the connection only when the link
497    /// itself failed.
498    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    /// The slot's connection, dropped first if the node closed it while it sat idle: a write
645    /// into a closed socket can look like it succeeded.
646    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    /// Ends a session the caller is done with: the head drops its record, and the node the
1209    /// context it kept (now if its connection is free, else the next time it is).
1210    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    /// Connects to every node once, so the head learns each one (or why it cannot) at start.
1244    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    /// Keeps idle connections open, drops ones a node closed, and finishes deferred session ends.
1268    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    /// Tries every node that failed and is due a retry, in parallel and outside the connection
1284    /// slots; one that answers is placed on again. Requests never wait on a down node.
1285    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                    // A joined node comes back when it dials in again.
1304                    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}