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