Skip to main content

ts_pd/
bootstrap.rs

1// Copyright (c) 2026 LightSeek Foundation
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in
11// all copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19// SOFTWARE.
20
21//! The bootstrap server: the HTTP rendezvous of a disaggregated deployment's
22//! data-source ranks (a Prefill node's for PD, an encode node's for EPD) and
23//! the ranks that read from them.
24//!
25//! * `PUT /route`: a source rank registers its control address under its
26//!   attention DP group and TP rank, with its node's parallel sizes.
27//! * `GET /route?engine_rank=-1&target_dp_group=-1`: the node's parallel
28//!   sizes (and, for a cache-transfer node, its logical cache layout and
29//!   per-stage field placement).
30//! * `GET /route?engine_rank=R&target_dp_group=G`: one rank's address.
31//! * `GET /health`: liveness.
32//!
33//! The routes and payloads are those of the Python server this replaced.
34//! Plain HTTP/1.1, one request per connection.
35
36use std::{
37    collections::HashMap,
38    io::{BufRead, BufReader, Read, Write},
39    net::{SocketAddr, TcpListener, TcpStream},
40    sync::{
41        atomic::{AtomicBool, Ordering},
42        Arc, Mutex,
43    },
44    thread,
45    time::Duration,
46};
47
48use serde_json::{json, Value};
49
50use crate::layout::{contract::Contract, route::validate_stage_fields};
51
52/// The largest request the server reads.
53const MAX_REQUEST_BYTES: usize = 4 << 20;
54
55/// How long one connection may take to send its request.
56const READ_TIMEOUT: Duration = Duration::from_secs(10);
57
58/// How often the accept loop checks for shutdown.
59const ACCEPT_POLL: Duration = Duration::from_millis(50);
60
61/// What a source node registers besides its ranks' addresses.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Registry {
64    /// Addresses and parallel sizes only (an EPD encode node).
65    Addresses,
66    /// Also the node's logical cache layout and per-stage field placement,
67    /// identical on every rank (a PD Prefill node).
68    CacheLayouts,
69}
70
71/// One registered source rank.
72#[derive(Debug, Clone)]
73struct RankAddress {
74    ip: Value,
75    port: i64,
76}
77
78/// What the source ranks registered.
79#[derive(Debug, Default)]
80struct Table {
81    world_size: Option<Value>,
82    dp_size: Option<Value>,
83    tp_size_per_dp_rank: Option<i64>,
84    pp_size: Option<i64>,
85    /// DP group -> TP rank within it -> address.
86    ranks: HashMap<i64, HashMap<i64, RankAddress>>,
87    /// The first registered layout's wire form, and the layout.
88    cache_layout: Option<(String, Contract)>,
89    cache_fields_by_stage: Option<Vec<Vec<String>>>,
90}
91
92/// A request the server refuses, with its status and text.
93struct Refusal(u16, String);
94
95fn internal(message: impl Into<String>) -> Refusal {
96    Refusal(500, message.into())
97}
98
99/// Python's `int(value)` of a JSON integer or numeric string.
100fn integer(value: Option<&Value>, name: &str) -> Result<i64, Refusal> {
101    let value = value.ok_or_else(|| internal(format!("missing {name}")))?;
102    value
103        .as_i64()
104        .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
105        .ok_or_else(|| internal(format!("{name} is not an integer")))
106}
107
108impl Table {
109    /// Record a cache-transfer node's layout and stage placement
110    /// (`MooncakeKVBootstrapServer._ingest_put_extra`): every rank registers
111    /// the same.
112    fn ingest_cache_layout(&mut self, data: &Value) -> Result<(), Refusal> {
113        let wire = data
114            .get("cache_layout")
115            .and_then(Value::as_str)
116            .filter(|wire| wire.is_ascii())
117            .ok_or_else(|| internal("CachePD bootstrap layout must be a string"))?;
118        let layout = Contract::from_wire(wire.as_bytes())
119            .map_err(|_| internal("CachePD bootstrap layout is invalid"))?;
120        let stages = data
121            .get("cache_fields_by_stage")
122            .and_then(Value::as_array)
123            .ok_or_else(|| internal("CachePD requires explicit stage field placement"))?
124            .iter()
125            .map(|stage| {
126                stage
127                    .as_array()
128                    .ok_or_else(|| internal("CachePD requires explicit stage field placement"))?
129                    .iter()
130                    .map(|field| {
131                        field.as_str().map(str::to_owned).ok_or_else(|| {
132                            internal(
133                                "cache stage placement must cover every logical field exactly once",
134                            )
135                        })
136                    })
137                    .collect::<Result<Vec<_>, _>>()
138            })
139            .collect::<Result<Vec<_>, _>>()?;
140        validate_stage_fields(&layout, &stages).map_err(|error| internal(error.0))?;
141        if stages.len() as i64 != integer(data.get("pp_size"), "pp_size")? {
142            return Err(internal(
143                "CachePD placement does not match pipeline stage count",
144            ));
145        }
146        if self
147            .cache_fields_by_stage
148            .as_ref()
149            .is_some_and(|known| *known != stages)
150        {
151            return Err(internal(
152                "CachePD prefill ranks registered incompatible field placement",
153            ));
154        }
155        if self
156            .cache_layout
157            .as_ref()
158            .is_some_and(|(_, known)| *known != layout)
159        {
160            return Err(internal(
161                "CachePD prefill ranks registered incompatible layouts",
162            ));
163        }
164        if self.cache_layout.is_none() {
165            self.cache_layout = Some((wire.to_owned(), layout));
166        }
167        self.cache_fields_by_stage = Some(stages);
168        Ok(())
169    }
170
171    /// `PUT /route`: a source rank registers.
172    fn register(&mut self, registry: Registry, body: &[u8]) -> Result<(), Refusal> {
173        let data: Value =
174            serde_json::from_slice(body).map_err(|_| internal("the registration is not JSON"))?;
175        let field = |key: &str| {
176            data.get(key)
177                .cloned()
178                .ok_or_else(|| internal(format!("missing {key}")))
179        };
180        let role = field("role")?;
181        let world_size = field("world_size")?;
182        let dp_size = field("dp_size")?;
183        let ip = field("rank_ip")?;
184        let port = integer(data.get("rank_port"), "rank_port")?;
185        let engine_rank = integer(data.get("engine_rank"), "engine_rank")?;
186        if registry == Registry::CacheLayouts {
187            self.ingest_cache_layout(&data)?;
188        }
189        self.world_size.get_or_insert_with(|| world_size.clone());
190        self.dp_size.get_or_insert_with(|| dp_size.clone());
191        if let Some(pp_size) = data.get("pp_size") {
192            self.pp_size = Some(integer(Some(pp_size), "pp_size")?);
193        }
194        // With PP the per-DP world spans the stages; the table keys by the
195        // dense stage-major rank.
196        let world = integer(Some(&world_size), "world_size")?;
197        let dp = integer(Some(&dp_size), "dp_size")?;
198        if dp == 0 {
199            return Err(internal("dp_size must not be zero"));
200        }
201        let tp_size_per_dp_rank = world.div_euclid(dp);
202        self.tp_size_per_dp_rank.get_or_insert(tp_size_per_dp_rank);
203        if role.as_str() == Some("Prefill") {
204            if tp_size_per_dp_rank == 0 {
205                return Err(internal("world_size must not be below dp_size"));
206            }
207            let dp_group = engine_rank.div_euclid(tp_size_per_dp_rank);
208            let tp_rank = engine_rank.rem_euclid(tp_size_per_dp_rank);
209            self.ranks
210                .entry(dp_group)
211                .or_default()
212                .insert(tp_rank, RankAddress { ip, port });
213            tracing::debug!(engine_rank, port, "a source rank registered");
214        }
215        Ok(())
216    }
217
218    /// `GET /route`: the node's parallel sizes, or one rank's address.
219    fn route(&self, registry: Registry, query: &HashMap<String, String>) -> Result<Value, Refusal> {
220        let (Some(engine_rank), Some(dp_group)) = (
221            query.get("engine_rank").filter(|value| !value.is_empty()),
222            query
223                .get("target_dp_group")
224                .filter(|value| !value.is_empty()),
225        ) else {
226            return Err(Refusal(
227                400,
228                "Missing inputs for bootstrap server.".to_owned(),
229            ));
230        };
231        let parse = |value: &str, name: &str| {
232            value
233                .trim()
234                .parse::<i64>()
235                .map_err(|_| internal(format!("{name} is not an integer")))
236        };
237        let engine_rank = parse(engine_rank, "engine_rank")?;
238        let dp_group = parse(dp_group, "target_dp_group")?;
239        if engine_rank == -1 && dp_group == -1 {
240            let mut info = json!({
241                "prefill_tp_size": self.world_size.clone().unwrap_or(Value::Null),
242                "prefill_dp_size": self.dp_size.clone().unwrap_or(Value::Null),
243                "prefill_pp_size": self.pp_size.unwrap_or(1),
244            });
245            if registry == Registry::CacheLayouts {
246                info["cache_layout"] = self
247                    .cache_layout
248                    .as_ref()
249                    .map_or(Value::Null, |(wire, _)| Value::from(wire.as_str()));
250                info["cache_fields_by_stage"] = self
251                    .cache_fields_by_stage
252                    .as_ref()
253                    .map_or(Value::Null, |stages| json!(stages));
254            }
255            return Ok(info);
256        }
257        let address = self
258            .ranks
259            .get(&dp_group)
260            .and_then(|ranks| ranks.get(&engine_rank))
261            .ok_or_else(|| internal("Bootstrap info not Found"))?;
262        Ok(json!({"rank_ip": address.ip, "rank_port": address.port}))
263    }
264}
265
266/// One parsed request.
267struct Request {
268    method: String,
269    path: String,
270    query: HashMap<String, String>,
271    body: Vec<u8>,
272}
273
274/// `%XX` and `+` decoding of a query component.
275fn percent_decode(text: &str) -> String {
276    let bytes = text.as_bytes();
277    let mut out = Vec::with_capacity(bytes.len());
278    let mut index = 0;
279    while index < bytes.len() {
280        let escaped = (bytes[index] == b'%')
281            .then(|| bytes.get(index + 1..index + 3))
282            .flatten()
283            .and_then(|hex| std::str::from_utf8(hex).ok())
284            .and_then(|hex| u8::from_str_radix(hex, 16).ok());
285        match (escaped, bytes[index]) {
286            (Some(byte), _) => {
287                out.push(byte);
288                index += 3;
289                continue;
290            }
291            (None, b'+') => out.push(b' '),
292            (None, byte) => out.push(byte),
293        }
294        index += 1;
295    }
296    String::from_utf8_lossy(&out).into_owned()
297}
298
299fn read_request(stream: &TcpStream) -> Option<Request> {
300    let mut reader = BufReader::new(stream.take(MAX_REQUEST_BYTES as u64));
301    let mut line = String::new();
302    reader.read_line(&mut line).ok()?;
303    let mut parts = line.split_whitespace();
304    let method = parts.next()?.to_owned();
305    let target = parts.next()?.to_owned();
306    let mut length = 0usize;
307    loop {
308        let mut header = String::new();
309        if reader.read_line(&mut header).ok()? == 0 {
310            break;
311        }
312        let header = header.trim_end();
313        if header.is_empty() {
314            break;
315        }
316        if let Some((name, value)) = header.split_once(':') {
317            if name.trim().eq_ignore_ascii_case("content-length") {
318                length = value.trim().parse().ok()?;
319            }
320        }
321    }
322    if length > MAX_REQUEST_BYTES {
323        return None;
324    }
325    let mut body = vec![0; length];
326    reader.read_exact(&mut body).ok()?;
327    let (path, query) = target.split_once('?').unwrap_or((target.as_str(), ""));
328    let query = query
329        .split('&')
330        .filter(|pair| !pair.is_empty())
331        .map(|pair| {
332            let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
333            (percent_decode(key), percent_decode(value))
334        })
335        .collect();
336    Some(Request {
337        method,
338        path: path.to_owned(),
339        query,
340        body,
341    })
342}
343
344fn reply(mut stream: &TcpStream, status: u16, content_type: &str, body: &str) {
345    let reason = match status {
346        200 => "OK",
347        400 => "Bad Request",
348        404 => "Not Found",
349        405 => "Method Not Allowed",
350        _ => "Internal Server Error",
351    };
352    let _ = write!(
353        stream,
354        "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\
355         Connection: close\r\n\r\n{body}",
356        body.len()
357    );
358    let _ = stream.flush();
359}
360
361fn serve(stream: TcpStream, registry: Registry, table: &Mutex<Table>) {
362    let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
363    let Some(request) = read_request(&stream) else {
364        reply(&stream, 400, "text/plain", "Bad Request");
365        return;
366    };
367    let answer = match (request.path.as_str(), request.method.as_str()) {
368        ("/health", "GET") => Ok(None),
369        ("/route", "PUT") => table
370            .lock()
371            .expect("the bootstrap table lock is poisoned")
372            .register(registry, &request.body)
373            .map(|()| None),
374        ("/route", "GET") => table
375            .lock()
376            .expect("the bootstrap table lock is poisoned")
377            .route(registry, &request.query)
378            .map(Some),
379        ("/route" | "/health", _) => Err(Refusal(405, "Method not allowed".to_owned())),
380        _ => Err(Refusal(404, "Not Found".to_owned())),
381    };
382    match answer {
383        Ok(None) => reply(&stream, 200, "text/plain", "OK"),
384        Ok(Some(body)) => reply(&stream, 200, "application/json", &body.to_string()),
385        Err(Refusal(status, message)) => {
386            if status == 500 {
387                tracing::error!(
388                    path = request.path,
389                    message,
390                    "the bootstrap server refused a request"
391                );
392            }
393            reply(&stream, status, "text/plain", &message);
394        }
395    }
396}
397
398/// Bind `port` on every interface: IPv6 and IPv4 where the host has IPv6,
399/// else IPv4.
400fn bind(port: u16) -> std::io::Result<TcpListener> {
401    TcpListener::bind(SocketAddr::from(([0u16; 8], port)))
402        .or_else(|_| TcpListener::bind(SocketAddr::from(([0u8; 4], port))))
403}
404
405/// A running bootstrap server.
406pub struct BootstrapServer {
407    port: u16,
408    stop: Arc<AtomicBool>,
409    thread: Option<thread::JoinHandle<()>>,
410}
411
412impl BootstrapServer {
413    /// Bind `port` (0 picks one) and serve `registry` until closed.
414    pub fn start(port: u16, registry: Registry) -> std::io::Result<Self> {
415        let listener = bind(port)?;
416        let port = listener.local_addr()?.port();
417        listener.set_nonblocking(true)?;
418        let stop = Arc::new(AtomicBool::new(false));
419        let table = Arc::new(Mutex::new(Table::default()));
420        let thread = {
421            let stop = stop.clone();
422            thread::Builder::new()
423                .name("ts-bootstrap".to_owned())
424                .spawn(move || {
425                    while !stop.load(Ordering::Relaxed) {
426                        match listener.accept() {
427                            Ok((stream, _)) => {
428                                let table = table.clone();
429                                let _ = stream.set_nonblocking(false);
430                                let spawned = thread::Builder::new()
431                                    .name("ts-bootstrap-conn".to_owned())
432                                    .spawn(move || serve(stream, registry, &table));
433                                if let Err(error) = spawned {
434                                    tracing::error!(%error, "cannot serve a bootstrap connection");
435                                }
436                            }
437                            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
438                                thread::sleep(ACCEPT_POLL);
439                            }
440                            Err(error) => {
441                                tracing::warn!(%error, "the bootstrap server failed to accept");
442                                thread::sleep(ACCEPT_POLL);
443                            }
444                        }
445                    }
446                })?
447        };
448        tracing::info!(port, ?registry, "bootstrap server up");
449        Ok(Self {
450            port,
451            stop,
452            thread: Some(thread),
453        })
454    }
455
456    /// The bound port.
457    pub fn port(&self) -> u16 {
458        self.port
459    }
460
461    /// Stop serving and release the port; idempotent.
462    pub fn close(&mut self) {
463        self.stop.store(true, Ordering::Relaxed);
464        if let Some(thread) = self.thread.take() {
465            let _ = thread.join();
466        }
467    }
468}
469
470impl Drop for BootstrapServer {
471    fn drop(&mut self) {
472        self.close();
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::http;
480
481    const TIMEOUT: Duration = Duration::from_secs(5);
482
483    fn url(server: &BootstrapServer, target: &str) -> String {
484        format!("http://127.0.0.1:{}{target}", server.port())
485    }
486
487    fn registration(engine_rank: i64, port: i64) -> Value {
488        json!({
489            "role": "Prefill",
490            "world_size": 4,
491            "dp_size": 2,
492            "rank_ip": "10.0.0.1",
493            "rank_port": port,
494            "engine_rank": engine_rank,
495        })
496    }
497
498    #[test]
499    fn source_ranks_register_and_are_looked_up() {
500        let mut server = BootstrapServer::start(0, Registry::Addresses).unwrap();
501        assert!(http::healthy(&url(&server, "/health"), TIMEOUT, TIMEOUT));
502        for (rank, port) in [(0, 7000), (3, 7003)] {
503            http::put_json(&url(&server, "/route"), &registration(rank, port), TIMEOUT).unwrap();
504        }
505        let info: Value = serde_json::from_str(
506            &http::get_text(
507                &url(&server, "/route?engine_rank=-1&target_dp_group=-1"),
508                TIMEOUT,
509            )
510            .unwrap(),
511        )
512        .unwrap();
513        assert_eq!(
514            info,
515            json!({"prefill_tp_size": 4, "prefill_dp_size": 2, "prefill_pp_size": 1})
516        );
517        // Rank 3 is TP rank 1 of DP group 1.
518        let rank: Value = serde_json::from_str(
519            &http::get_text(
520                &url(&server, "/route?engine_rank=1&target_dp_group=1"),
521                TIMEOUT,
522            )
523            .unwrap(),
524        )
525        .unwrap();
526        assert_eq!(rank, json!({"rank_ip": "10.0.0.1", "rank_port": 7003}));
527        let missing = http::get_text(
528            &url(&server, "/route?engine_rank=1&target_dp_group=0"),
529            TIMEOUT,
530        );
531        assert!(missing.unwrap_err().starts_with("500"));
532        let incomplete = http::get_text(&url(&server, "/route?engine_rank=1"), TIMEOUT);
533        assert!(incomplete.unwrap_err().starts_with("400"));
534        server.close();
535        assert!(!http::healthy(&url(&server, "/health"), TIMEOUT, TIMEOUT));
536    }
537
538    fn layout(page_count: i64) -> String {
539        json!({
540            "plan": {
541                "prefix_granularity": 16,
542                "lcm_block_bytes": 64,
543                "num_lcm_blocks": page_count - 1,
544                "groups": [{"group_id": "kv", "cache_blocks_per_lcm_block": 1,
545                            "page_count": page_count}],
546                "planes": [{"plane_id": "p0", "bytes_per_lcm_block": 64, "arena_offset_bytes": 0}],
547                "fields": [
548                    {"group_id": "kv", "field_id": "layer.0.kv", "plane_id": "p0", "shape": [16],
549                     "dtype": "bfloat16", "field_offset_bytes": 0, "page_stride_bytes": 64},
550                    {"group_id": "kv", "field_id": "layer.1.kv", "plane_id": "p0", "shape": [16],
551                     "dtype": "bfloat16", "field_offset_bytes": 32, "page_stride_bytes": 64}
552                ],
553                "resident_block_bytes": null
554            },
555            "group_specs": [{"group_id": "kv", "attn_kind": "full_history", "rows_per_page": 16,
556                             "entry_stride_tokens": 1, "sliding_window_tokens": null,
557                             "replayable": false, "transfer_policy": "full_suffix",
558                             "shard_count": 1}],
559            "transfer_schema": {"fields": []},
560            "field_addresses": null
561        })
562        .to_string()
563    }
564
565    fn cache_registration(engine_rank: i64, layout: &str, stages: Value) -> Value {
566        let mut data = registration(engine_rank, 7000 + engine_rank);
567        data["pp_size"] = json!(stages.as_array().unwrap().len());
568        data["cache_layout"] = json!(layout);
569        data["cache_fields_by_stage"] = stages;
570        data
571    }
572
573    #[test]
574    fn a_cache_transfer_node_registers_one_layout_and_placement() {
575        let server = BootstrapServer::start(0, Registry::CacheLayouts).unwrap();
576        let route = url(&server, "/route");
577        let stages = json!([["layer.0.kv"], ["layer.1.kv"]]);
578        http::put_json(
579            &route,
580            &cache_registration(0, &layout(8), stages.clone()),
581            TIMEOUT,
582        )
583        .unwrap();
584        // Another layout, a placement missing a field, or one whose stage
585        // count is not the node's: refused, and nothing recorded.
586        for refused in [
587            cache_registration(1, &layout(9), stages.clone()),
588            cache_registration(1, &layout(8), json!([["layer.0.kv"]])),
589            cache_registration(1, &layout(8), json!([["layer.0.kv", "layer.1.kv"]])),
590        ] {
591            let error = http::put_json(&route, &refused, TIMEOUT).unwrap_err();
592            assert!(error.starts_with("500"), "{error}");
593        }
594        let mut wrong_pp = cache_registration(1, &layout(8), stages.clone());
595        wrong_pp["pp_size"] = json!(1);
596        assert!(http::put_json(&route, &wrong_pp, TIMEOUT).is_err());
597        let lookup = url(&server, "/route?engine_rank=1&target_dp_group=0");
598        assert!(http::get_text(&lookup, TIMEOUT).is_err());
599
600        http::put_json(
601            &route,
602            &cache_registration(1, &layout(8), stages.clone()),
603            TIMEOUT,
604        )
605        .unwrap();
606        let info: Value = serde_json::from_str(
607            &http::get_text(
608                &url(&server, "/route?engine_rank=-1&target_dp_group=-1"),
609                TIMEOUT,
610            )
611            .unwrap(),
612        )
613        .unwrap();
614        assert_eq!(info["prefill_pp_size"], json!(2));
615        assert_eq!(info["cache_fields_by_stage"], stages);
616        let served =
617            Contract::from_wire(info["cache_layout"].as_str().unwrap().as_bytes()).unwrap();
618        assert_eq!(served, Contract::from_wire(layout(8).as_bytes()).unwrap());
619        assert!(http::get_text(&lookup, TIMEOUT).is_ok());
620    }
621
622    #[test]
623    fn a_taken_port_is_refused() {
624        let server = BootstrapServer::start(0, Registry::Addresses).unwrap();
625        assert!(BootstrapServer::start(server.port(), Registry::Addresses).is_err());
626    }
627}