Skip to main content

kevy_rt/
runtime_run.rs

1//! [`Runtime::run`] — validate config, build the cross-shard mesh and
2//! every [`Shard`], spawn one thread per core, and join. Cold startup
3//! path (runs once per process); split out of `runtime.rs` for the
4//! 500-LOC house rule and decomposed into per-stage helpers.
5
6use crate::Commands;
7use crate::message::{Inbound, PubSubPatternReg, PubSubReg};
8use crate::runtime::Runtime;
9use crate::shard::{CachePadded, Shard};
10use kevy_map::KevyMap;
11use kevy_persist::Aof;
12use kevy_ring::{Consumer, Producer};
13use kevy_store::Store;
14use kevy_sys::{Poller, Waker, tcp_listen_reuseport, waker};
15use std::collections::{HashMap, VecDeque};
16use std::io;
17use std::sync::atomic::{AtomicBool, AtomicU64};
18use std::sync::{Arc, RwLock};
19
20/// Cross-shard shared state built once before any shard spawns: the
21/// per-core-pair SPSC ring mesh, wakers, park flags, inbox-dirty
22/// bitmaps, and the pub/sub registries.
23struct Shared {
24    /// `outboxes[i][j]` = shard i's producer half toward shard j.
25    outboxes: Vec<Vec<Option<Producer<Inbound>>>>,
26    /// `inboxes[j][i]` = shard j's consumer half from shard i.
27    inboxes: Vec<Vec<Option<Consumer<Inbound>>>>,
28    wakers: Vec<Arc<Waker>>,
29    parked: Vec<Arc<CachePadded<AtomicBool>>>,
30    inbound_dirty: Vec<Arc<CachePadded<AtomicU64>>>,
31    /// Shared pub/sub channel registry (one per server, read on every
32    /// PUBLISH) + the pattern registry (empty in steady state — the
33    /// channel-only PUBLISH path skips the walk when so).
34    pubsub: PubSubReg,
35    pubsub_patterns: PubSubPatternReg,
36}
37
38impl Shared {
39    fn build(n: usize, ring_capacity: usize) -> io::Result<Shared> {
40        // One lock-free SPSC ring per ordered core-pair (i→j): the producer
41        // goes to shard i's outbox[j], the consumer to shard j's inbox[i].
42        // There is no self-ring — a shard runs its own commands inline,
43        // never over a ring.
44        let mut outboxes: Vec<Vec<Option<Producer<Inbound>>>> =
45            (0..n).map(|_| (0..n).map(|_| None).collect()).collect();
46        let mut inboxes: Vec<Vec<Option<Consumer<Inbound>>>> =
47            (0..n).map(|_| (0..n).map(|_| None).collect()).collect();
48        for i in 0..n {
49            for j in 0..n {
50                if i == j {
51                    continue;
52                }
53                let (p, c) = kevy_ring::ring::<Inbound>(ring_capacity);
54                outboxes[i][j] = Some(p);
55                inboxes[j][i] = Some(c);
56            }
57        }
58        let mut wakers: Vec<Arc<Waker>> = Vec::with_capacity(n);
59        for _ in 0..n {
60            wakers.push(Arc::new(waker()?));
61        }
62        let parked: Vec<Arc<CachePadded<AtomicBool>>> =
63            (0..n).map(|_| Arc::new(CachePadded::new(AtomicBool::new(false)))).collect();
64        // Per-shard inbox-dirty bitmaps (one u64 bit per peer src).
65        // Senders OR a bit on the target's dirty word; the target's
66        // `drain_inbound_core` swaps and short-circuits when 0.
67        assert!(
68            n <= 64,
69            "kevy-rt: shard count {n} exceeds 64 — inbound_dirty bitmap holds one bit per peer in a u64. Reduce --threads or extend to a multi-word bitmap.",
70        );
71        // Pad each Arc<AtomicU64> to a full 64-byte cache
72        // line. A `perf c2c` diagnostic showed cross-shard fetch_or vs. owner
73        // swap on adjacent atomics bounced cache lines between cores.
74        let inbound_dirty: Vec<Arc<CachePadded<AtomicU64>>> =
75            (0..n).map(|_| Arc::new(CachePadded::new(AtomicU64::new(0)))).collect();
76        Ok(Shared {
77            outboxes,
78            inboxes,
79            wakers,
80            parked,
81            inbound_dirty,
82            pubsub: Arc::new(RwLock::new(HashMap::new())),
83            pubsub_patterns: Arc::new(RwLock::new(Vec::new())),
84        })
85    }
86}
87
88impl<C: Commands> Runtime<C> {
89    /// Spawn one thread per shard and run until `stop` is set.
90    pub fn run(mut self, stop: Arc<AtomicBool>) -> io::Result<()> {
91        let _dir_lock = self.claim_data_dir()?;
92        let n = self.nshards;
93        // Single global bio thread. Spawn BEFORE shards so
94        // every shard's first overwrite already has a live consumer. The
95        // held `bio_send` is cloned into every Store below; shutdown
96        // ordering (shards join → Stores drop their Senders → this fn's
97        // `bio_send` drops → channel closes → bio thread exits → its
98        // `join()` below completes) guards against process tear-down
99        // while a final large free is in flight (`madvise`/`munmap`
100        // need the process alive). See `crate::bio` for the rationale.
101        let (bio_send, bio_handle) = crate::bio::spawn();
102        self.validate_port_ranges(n)?;
103        let mut shared = Shared::build(n, self.ring_capacity)?;
104        self.reconcile_layout(n)?;
105        // UDS listener: only ONE per server (no SO_REUSEPORT for AF_UNIX),
106        // so it lives on shard 0. Bound up-front so a bind failure aborts
107        // before any shard spawns.
108        let mut unix_listener: Option<kevy_sys::Socket> = None;
109        if let Some(p) = self.unix_socket_path.as_ref() {
110            let path_bytes = p.to_string_lossy();
111            unix_listener = Some(kevy_sys::unix_listen(path_bytes.as_bytes(), 1024)?);
112        }
113        // Build every shard up front so a bind/open failure aborts before
114        // we spawn.
115        let shards = self.build_shards(n, &mut shared, &bio_send, unix_listener)?;
116        let (use_uring, uring_forced) = reactor_choice();
117        let mut handles = Vec::with_capacity(n);
118        for shard in shards {
119            let stop = stop.clone();
120            handles.push(std::thread::spawn(move || {
121                run_shard_thread(shard, stop, use_uring, uring_forced);
122            }));
123        }
124        for h in handles {
125            let _ = h.join();
126        }
127        // Bio shutdown: see the bio-spawn comment above.
128        drop(bio_send);
129        let _ = bio_handle.join();
130        Ok(())
131    }
132
133    /// Reject a cluster / replication port range that overflows u16 up
134    /// front (loud) instead of wrapping a listener onto a low/privileged
135    /// port while CLUSTER SLOTS advertises 65536+.
136    fn validate_port_ranges(&self, n: usize) -> io::Result<()> {
137        if let Some(base) = self.cluster_port_base
138            && base as usize + n > u16::MAX as usize + 1
139        {
140            return Err(io::Error::new(
141                io::ErrorKind::InvalidInput,
142                format!(
143                    "cluster port range {base}..={} exceeds 65535 ({n} shards)",
144                    base as usize + n - 1
145                ),
146            ));
147        }
148        // Same overflow check for the replication port range
149        // (`base + 0 .. base + n`). See Issue Ledger I2 for the
150        // per-shard listener decision.
151        if let Some(base) = self.replication_port_base
152            && base as usize + n > u16::MAX as usize + 1
153        {
154            return Err(io::Error::new(
155                io::ErrorKind::InvalidInput,
156                format!(
157                    "replication port range {base}..={} exceeds 65535 ({n} shards)",
158                    base as usize + n - 1
159                ),
160            ));
161        }
162        Ok(())
163    }
164
165    /// One server per data dir (same condition as [`Self::reconcile_layout`]:
166    /// a pure in-memory run against an unused dir claims nothing). A dir
167    /// already claimed errors here, before any shard touches a file,
168    /// instead of two writers interleaving one AOF.
169    fn claim_data_dir(&self) -> io::Result<Option<kevy_persist::DirLock>> {
170        if self.enable_aof
171            || self.cluster_port_base.is_some()
172            || crate::reshard::has_kevy_files(&self.data_dir)
173        {
174            return Ok(Some(kevy_persist::DirLock::acquire(&self.data_dir)?));
175        }
176        Ok(None)
177    }
178
179    /// Reconcile the on-disk shard layout (count + routing) before any
180    /// shard loads its files; a mismatch re-homes every key once, here.
181    /// Skipped for a pure in-memory run against a dir with no kevy files.
182    /// Cluster mode always records the layout even with AOF off and an
183    /// empty dir: a later SAVE writes slot-distributed `dump-{i}.rdb`, and
184    /// without a meta a non-cluster restart would read them as KevyHash
185    /// and silently strand every key.
186    fn reconcile_layout(&self, n: usize) -> io::Result<()> {
187        if self.enable_aof
188            || self.cluster_port_base.is_some()
189            || crate::reshard::has_kevy_files(&self.data_dir)
190        {
191            let routing = if self.cluster_port_base.is_some() {
192                kevy_persist::Routing::Slots
193            } else {
194                kevy_persist::Routing::KevyHash
195            };
196            crate::reshard::ensure_layout(
197                &self.data_dir,
198                n,
199                routing,
200                &self.commands,
201                self.resolved_tier_budget(),
202                &self.tier_root(),
203            )?;
204        }
205        Ok(())
206    }
207
208    /// Advertised cluster topology (None = cluster off). A 0.0.0.0 bind
209    /// advertises 127.0.0.1 — an unroutable redirect target would strand
210    /// every cluster client (single-machine scope; no announce-ip knob).
211    fn cluster_topo(&self) -> Option<crate::cluster::ClusterTopo> {
212        self.cluster_port_base.map(|base| crate::cluster::ClusterTopo {
213            ip: if self.ip == [0, 0, 0, 0] { [127, 0, 0, 1] } else { self.ip },
214            port_base: base,
215        })
216    }
217
218    /// Build all `n` shards: per-shard listeners + store + the flat
219    /// `Shard` field-init. Field-by-field comments live with the struct
220    /// definition in [`crate::shard`].
221    // LOC-WAIVER: flat per-shard construction table — listener/socket
222    // setup then one line per Shard field; no control flow to split.
223    fn build_shards(
224        &mut self,
225        n: usize,
226        shared: &mut Shared,
227        bio_send: &kevy_store::BioDropSender,
228        mut unix_listener: Option<kevy_sys::Socket>,
229    ) -> io::Result<Vec<Shard<C>>> {
230        let topo = self.cluster_topo();
231        let mut shards = Vec::with_capacity(n);
232        for id in 0..n {
233            let arms_accept = self.accept_shards.is_none_or(|k| id < k);
234            // Off-accept-set shards skip the SO_REUSEPORT bind so
235            // the kernel routes new conns only to the armed subset.
236            let listener = if arms_accept {
237                Some(tcp_listen_reuseport(self.ip, self.port, 1024)?)
238            } else {
239                None
240            };
241            // Cluster mode: a second, deterministic per-shard listener at
242            // port_base + id (plain bind — exactly one owner per port).
243            let cluster_listener = match self.cluster_port_base {
244                Some(base) => Some(kevy_sys::tcp_listen(self.ip, base + id as u16, 1024)?),
245                None => None,
246            };
247            // Replication listener (per Issue Ledger I2): per-shard
248            // deterministic port, same `tcp_listen` (no SO_REUSEPORT)
249            // pattern as cluster. A replica's shard-aware client will
250            // connect to every `base + id` to mirror the full keyspace.
251            let replication_listener = match self.replication_port_base {
252                Some(base) => Some(kevy_sys::tcp_listen(self.ip, base + id as u16, 1024)?),
253                None => None,
254            };
255            let aof = if self.enable_aof {
256                Some(Aof::open_with_repair(
257                    &kevy_persist::layout::aof_path(&self.data_dir, id),
258                    self.appendfsync,
259                    self.replay_resync,
260                )?)
261            } else {
262                None
263            };
264            let mut store = Store::new();
265            // The reactor loop refreshes the store clock once per batch, so
266            // lazy expiry can trust the cached clock (skip per-command
267            // `Instant::now()`).
268            store.set_cached_clock(true);
269            // Hand the bio-drop channel sender to the store so
270            // SET overwrites of heavy values (Arc<[u8]> ≥ 256 B, non-empty
271            // collections) get freed off-reactor. Sender clone is cheap
272            // (`Arc::clone`); the bio thread is shared across all shards
273            // (single global thread, mirrors valkey `bio.c`).
274            store.set_bio_drop_sender(bio_send.clone());
275            // Tiering: the process budget — resolved
276            // bytes from the builder (`[tiering]` TOML/CLI/env full
277            // surface), or the minimal `KEVY_TIER_BUDGET` plain-bytes
278            // env knob — split evenly across shards; per-shard cold
279            // tier under `<tier root>/<id>`.
280            if let Some(total) = self.resolved_tier_budget() {
281                store.enable_tiering(
282                    &self.tier_root().join(id.to_string()),
283                    Self::per_shard_tier_budget(total, n),
284                )?;
285            }
286            self.commands.on_shard_init(&mut store);
287            shards.push(Shard {
288                #[cfg(target_os = "linux")]
289                aof_offload: Default::default(),
290                aof_lane: Default::default(),
291                pending_fsync_policy: None,
292                held_responses: Vec::new(),
293                rewrite_handoff: None,
294                rewrite_rate_mark: None,
295                rewrite_calm_ticks: 0,
296                xshard_inflight: 0,
297                id,
298                nshards: n,
299                cluster: topo.clone(),
300                cluster_listener,
301                // UDS: only shard 0 holds the (single) unix listener.
302                unix_listener: if id == 0 { unix_listener.take() } else { None },
303                store,
304                commands: self.commands.clone(),
305                poller: Poller::new()?,
306                listener,
307                waker: shared.wakers[id].clone(),
308                inboxes: std::mem::take(&mut shared.inboxes[id]),
309                outboxes: std::mem::take(&mut shared.outboxes[id]),
310                backlog: (0..n).map(|_| VecDeque::new()).collect(),
311                wakers: shared.wakers.clone(),
312                conns: KevyMap::new(),
313                arm_pending: Vec::new(),
314                closing_uring_conns: Vec::new(),
315                fd_to_conn: KevyMap::new(),
316                // Conn ids stride by shard count from a per-shard
317                // start, so every id is unique across the whole
318                // instance (CLIENT ID / CLIENT KILL ID contract) and
319                // still allocation-free per accept.
320                next_conn_id: id as u64 + 1,
321                conn_id_step: n as u64,
322                events: Vec::with_capacity(1024),
323                read_buf: vec![0u8; 64 * 1024],
324                pending_wakes: 0,
325                backlog_nonempty: 0,
326                request_batch_nonempty: 0,
327                publish_batch_nonempty: 0,
328                parked: shared.parked.clone(),
329                inbound_dirty: shared.inbound_dirty.clone(),
330                data_dir: self.data_dir.clone(),
331                aof,
332                replicate: if self.enable_replication || self.feed_enabled {
333                    let budget = if self.feed_enabled {
334                        self.replication_buffer_size.max(self.feed_buffer_size)
335                    } else {
336                        self.replication_buffer_size
337                    };
338                    let boot = kevy_persist::feed_meta::load_feed_boot(&self.data_dir, id)?;
339                    let mut src = kevy_replicate::source::ReplicationSource::new(
340                        usize::try_from(budget).unwrap_or(usize::MAX),
341                    );
342                    src.set_next_offset(boot.next_offset);
343                    Some(kevy_replicate::feed::FeedSource::new(boot.generation, src))
344                } else {
345                    None
346                },
347                replication_listener,
348                replicas: Vec::new(),
349                slots: kevy_replicate::slot::SlotTable::new(),
350                replication_reconnect_window_ms: self.replication_reconnect_window_ms,
351                replication_epoch: std::time::Instant::now(),
352                replica_inbox: self.replica_inboxes.get_mut(id).and_then(Option::take),
353                replica_snapshot_buf: Vec::new(),
354                replica_applied_next: 0,
355                repl_waiters: Vec::new(),
356                seen_promotion_epoch: None,
357                persist: crate::persist_worker::PersistWorker::new(),
358                auto_aof_rewrite_pct: self.auto_aof_rewrite_pct,
359                auto_aof_rewrite_bytes: self.auto_aof_rewrite_bytes,
360                auto_aof_rewrite_interval_secs: self.auto_aof_rewrite_interval_secs,
361                replay_resync: self.replay_resync,
362                auto_aof_rewrite_min_size: self.auto_aof_rewrite_min_size,
363                dirty: Vec::new(),
364                pubsub: shared.pubsub.clone(),
365                pubsub_patterns: shared.pubsub_patterns.clone(),
366                psub_local: HashMap::new(),
367                subs_by_channel: HashMap::new(),
368                publish_batch: (0..n).map(|_| Vec::new()).collect(),
369                request_batch: (0..n).map(|_| Vec::new()).collect(),
370                // Seed from the live config at construction, not default():
371                // these flags were otherwise blind until the first 100 ms
372                // shard tick, so a write landing before that never fired
373                // its keyspace notification (CI-visible flake; a real
374                // startup gap for any pre-configured notify_keyspace_events).
375                notify_flags: self.commands.live_runtime_config().notify_flags.unwrap_or_default(),
376                spin_limit: self.spin_limit,
377                arms_accept: self.accept_shards.is_none_or(|n| id < n),
378                max_clients_per_shard: if self.max_clients == 0 {
379                    0
380                } else {
381                    self.max_clients.div_ceil(n)
382                },
383                rejected_connections: 0,
384                input_hard_limit: std::env::var("KEVY_DEBUG_INPUT_LIMIT")
385                    .ok()
386                    .and_then(|v| v.parse().ok())
387                    .unwrap_or(crate::CLIENT_INPUT_HARD_LIMIT),
388                // `Poller::wait` takes the timeout as `i32` (POSIX
389                // poll/epoll convention). The config knob is `u32` —
390                // we clamp to i32::MAX, far above any sane park-timeout.
391                park_timeout_ms: self.park_timeout_ms.min(i32::MAX as u32) as i32,
392                tick_check_every: self.tick_check_every,
393                slowlog: crate::exec_slowlog::SlowlogState::new(
394                    self.slowlog_slower_than_micros,
395                    self.slowlog_max_len,
396                ),
397                blocked: crate::blocked::BlockedClients::new(),
398                origin_blocks: std::collections::HashMap::new(),
399                xwaiters: crate::block_xshard::XShardWaiters::default(),
400                serve_confirm: std::collections::HashMap::new(),
401                reply_scratch: Vec::with_capacity(4096),
402                argv_pool: kevy_resp::ArgvPool::new(),
403            });
404        }
405        Ok(shards)
406    }
407}
408
409/// Reactor selection on Linux:
410///   KEVY_IO_URING unset → auto: try io_uring, fall back to epoll if the
411///     host can't build the ring (probe below) — startup never fails.
412///   KEVY_IO_URING=0/off/no/false → force the epoll readiness reactor.
413///   KEVY_IO_URING=<anything else> → force io_uring (no fallback; a
414///     setup failure then surfaces loudly — for benchmarks / tests).
415/// The probe creates+drops a real ring with the run_uring parameters, so
416/// it catches a seccomp-blocked io_uring_setup (Docker's default profile)
417/// and pre-5.19 kernels before any shard loads data. (macOS = kqueue.)
418#[cfg(target_os = "linux")]
419fn reactor_choice() -> (bool, bool) {
420    match std::env::var("KEVY_IO_URING").ok().as_deref() {
421        Some("0") | Some("off") | Some("no") | Some("false") => (false, true),
422        Some(_) => (true, true),
423        None => {
424            let avail = crate::uring_reactor::io_uring_available();
425            eprintln!(
426                "kevy: reactor = {} (io_uring {})",
427                if avail { "io_uring" } else { "epoll" },
428                if avail {
429                    "available"
430                } else {
431                    "unavailable — kernel <5.19 or seccomp; using epoll"
432                },
433            );
434            (avail, false)
435        }
436    }
437}
438
439/// Non-Linux: always the readiness reactor (kqueue on macOS).
440#[cfg(not(target_os = "linux"))]
441fn reactor_choice() -> (bool, bool) {
442    (false, false)
443}
444
445/// One shard thread's body: pick the reactor and run it to completion.
446///
447/// Per-shard ring setup is attempted BEFORE committing to the
448/// io_uring path. The global probe proves one ring builds; N shards
449/// need N rings, and a late failure (ENOMEM under pressure) used to
450/// kill the shard thread and leave a half-dead server (found via
451/// GH-runner CI: blocking_cross_shard hangs). Auto mode now degrades
452/// that shard to epoll, loudly. A forced KEVY_IO_URING=1 keeps the
453/// old fail-loud contract.
454fn run_shard_thread<C: Commands>(
455    shard: Shard<C>,
456    stop: Arc<AtomicBool>,
457    use_uring: bool,
458    uring_forced: bool,
459) {
460    let id = shard.id;
461    #[cfg(target_os = "linux")]
462    let res = if use_uring {
463        match crate::uring_reactor::build_uring() {
464            Ok(pair) => shard.run_uring(pair, stop),
465            Err(e) if !uring_forced => {
466                eprintln!(
467                    "kevy: shard {id}: io_uring setup failed ({e}); \
468                     falling back to the epoll reactor for this shard"
469                );
470                shard.run(stop)
471            }
472            Err(e) => Err(e),
473        }
474    } else {
475        shard.run(stop)
476    };
477    #[cfg(not(target_os = "linux"))]
478    let res = {
479        let _ = (use_uring, uring_forced);
480        shard.run(stop)
481    };
482    if let Err(e) = res {
483        eprintln!("kevy: shard {id} exited with error: {e}");
484    }
485}