Skip to main content

khive_runtime/daemon/
load_limits.rs

1//! Load limits for the daemon: the cap on concurrently served connections with its
2//! busy refusal, and the bound on recall serve-ledger tasks.
3
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7#[cfg(unix)]
8use tokio::net::UnixStream;
9
10use super::spawn_named_tracked_task;
11#[cfg(unix)]
12use super::{write_frame, DaemonResponseFrame, PROTOCOL_VERSION};
13
14// ── connection admission ──────────────────────────────────────────────────────
15
16/// Environment variable that sets the cap on concurrently served connections.
17/// A zero or non-integer value is ignored with a warning and the default applies.
18#[cfg(unix)]
19const MAX_CONNECTIONS_ENV: &str = "KHIVE_DAEMON_MAX_CONNECTIONS";
20
21/// Default cap on concurrently served connections, used when
22/// `KHIVE_DAEMON_MAX_CONNECTIONS` is unset. Every open connection holds a task
23/// and a file descriptor until its peer closes it, and clients open one
24/// connection per request, so the default is far above the concurrency of an
25/// ordinary client population. The cap that is enforced is this value, or the
26/// configured one, reduced to fit the process's descriptor limit (see
27/// [`effective_connection_cap`]); a daemon whose limit leaves room for it
28/// behaves exactly as it did before the cap existed.
29#[cfg(unix)]
30const DEFAULT_MAX_CONNECTIONS: usize = 512;
31
32/// Descriptors the daemon holds for purposes other than serving a connection.
33/// They are kept out of the connection cap so that the cap is reached before the
34/// process runs out of descriptors. A running daemon measured at rest held 160:
35/// 130 on five SQLite database files (each backend pool holds a writer, up to 8
36/// readers and one checkpoint connection, each with a database and a WAL
37/// descriptor, plus one shared-memory descriptor per file) and 30 on the
38/// listener and its peers, event-loop handles, log files, ANN and model files
39/// and shared libraries. 192 is those 160 plus 32 for growth (one more database
40/// file holds about 25) and for descriptors that a request opens while it runs
41/// (outbound sockets, child-process pipes, blob reads), which are not measured.
42#[cfg(unix)]
43const RESERVED_DESCRIPTORS: u64 = 192;
44
45/// Where the enforced connection cap came from.
46#[cfg(unix)]
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(super) enum CapSource {
49    /// `DEFAULT_MAX_CONNECTIONS`.
50    Builtin,
51    /// `KHIVE_DAEMON_MAX_CONNECTIONS`.
52    Configured,
53    /// Reduced to what the descriptor limit leaves after `RESERVED_DESCRIPTORS`.
54    DescriptorLimit,
55}
56
57/// Longest the accept loop waits to write a busy refusal. The frame is a few
58/// hundred bytes written into the empty buffer of a connection that has not been
59/// sent anything yet, so a live peer never reaches this bound.
60#[cfg(unix)]
61const BUSY_REFUSAL_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
62
63/// Parse a limit that must be a positive integer. Absent, zero, negative and
64/// non-integer values are all `None`.
65pub(super) fn parse_positive_limit(raw: Option<&str>) -> Option<u64> {
66    raw.and_then(|value| value.trim().parse::<u64>().ok())
67        .filter(|value| *value > 0)
68}
69
70/// Read a positive limit from the environment. An unset variable is `None`
71/// silently; a set but unusable value is `None` with a warning, so the caller's
72/// default applies and the operator is told the setting was ignored.
73fn positive_limit_from_env(name: &str) -> Option<u64> {
74    let raw = std::env::var(name).ok()?;
75    let parsed = parse_positive_limit(Some(&raw));
76    if parsed.is_none() {
77        tracing::warn!(
78            variable = name,
79            value = %raw,
80            "ignoring a limit that is not a positive integer; the default applies"
81        );
82    }
83    parsed
84}
85
86/// Like [`positive_limit_from_env`], as a `usize`.
87#[cfg(unix)]
88fn positive_usize_from_env(name: &str) -> Option<usize> {
89    let value = positive_limit_from_env(name)?;
90    Some(usize::try_from(value).unwrap_or(usize::MAX))
91}
92
93/// The process's soft `RLIMIT_NOFILE`, or `None` when the call fails.
94#[cfg(unix)]
95pub(super) fn soft_nofile_limit() -> Option<u64> {
96    let mut limit = libc::rlimit {
97        rlim_cur: 0,
98        rlim_max: 0,
99    };
100    // SAFETY: `limit` is a valid, writable rlimit for the duration of the call.
101    if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) } != 0 {
102        return None;
103    }
104    // `rlim_t` varies in width and signedness across Unix targets. The cast gives
105    // the u64 the cap arithmetic uses even where it is redundant.
106    #[allow(clippy::unnecessary_cast)]
107    let soft = limit.rlim_cur as u64;
108    Some(soft)
109}
110
111/// The connection cap to enforce: the configured value (or the default),
112/// reduced to `soft_nofile - RESERVED_DESCRIPTORS` when that is smaller, and
113/// never below 1. `soft_nofile` is the process's soft descriptor limit, or
114/// `None` when it could not be read, in which case the configured or default
115/// value stands.
116#[cfg(unix)]
117pub(super) fn effective_connection_cap(
118    configured: Option<usize>,
119    soft_nofile: Option<u64>,
120) -> (usize, CapSource) {
121    let (wanted, source) = match configured {
122        Some(value) => (value, CapSource::Configured),
123        None => (DEFAULT_MAX_CONNECTIONS, CapSource::Builtin),
124    };
125    let room = match soft_nofile {
126        Some(soft) => soft.saturating_sub(RESERVED_DESCRIPTORS),
127        None => u64::MAX,
128    };
129    let room = usize::try_from(room).unwrap_or(usize::MAX);
130    let reduced = room < wanted;
131    let cap = if reduced { room } else { wanted };
132    let source = if reduced {
133        CapSource::DescriptorLimit
134    } else {
135        source
136    };
137    (cap.max(1), source)
138}
139
140/// Admission control for new connections on the daemon socket: one permit per
141/// connection, taken before the connection task is spawned and released when
142/// the task ends. Admission never waits, so a connection past the cap is
143/// refused at once instead of queueing work behind it.
144#[cfg(unix)]
145pub(super) struct ConnectionAdmission {
146    /// The cap that is enforced.
147    limit: usize,
148    /// The configured or default cap, present only when the process's
149    /// descriptor limit forced `limit` below it.
150    configured: Option<usize>,
151    permits: Arc<tokio::sync::Semaphore>,
152    refused: std::sync::atomic::AtomicU64,
153}
154
155#[cfg(unix)]
156impl ConnectionAdmission {
157    pub(super) fn new(limit: usize) -> Self {
158        let limit = limit.clamp(1, tokio::sync::Semaphore::MAX_PERMITS);
159        Self {
160            limit,
161            configured: None,
162            permits: Arc::new(tokio::sync::Semaphore::new(limit)),
163            refused: std::sync::atomic::AtomicU64::new(0),
164        }
165    }
166
167    /// Start-up path: the enforced cap is [`effective_connection_cap`] of the
168    /// configured value and the soft descriptor limit. A cap reduced to fit the
169    /// limit, or a limit that could not be read, is logged once here.
170    pub(super) fn from_limits(configured: Option<usize>, soft_nofile: Option<u64>) -> Self {
171        let (limit, source) = effective_connection_cap(configured, soft_nofile);
172        let mut admission = Self::new(limit);
173        match soft_nofile {
174            None => tracing::warn!(
175                cap = admission.limit,
176                "could not read the descriptor limit; the connection cap is not reduced to fit it"
177            ),
178            Some(soft) if source == CapSource::DescriptorLimit => {
179                let wanted = configured.unwrap_or(DEFAULT_MAX_CONNECTIONS);
180                admission.configured = Some(wanted);
181                tracing::warn!(
182                    configured_cap = wanted,
183                    effective_cap = admission.limit,
184                    soft_nofile = soft,
185                    reserved_descriptors = RESERVED_DESCRIPTORS,
186                    "connection cap reduced to fit the process descriptor limit"
187                );
188            }
189            Some(_) => {}
190        }
191        admission
192    }
193
194    pub(super) fn from_env() -> Self {
195        Self::from_limits(
196            positive_usize_from_env(MAX_CONNECTIONS_ENV),
197            soft_nofile_limit(),
198        )
199    }
200
201    /// Take a permit for one connection. `Err` carries the running count of
202    /// refused connections, this one included.
203    pub(super) fn try_admit(&self) -> Result<tokio::sync::OwnedSemaphorePermit, u64> {
204        Arc::clone(&self.permits).try_acquire_owned().map_err(|_| {
205            self.refused
206                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
207                + 1
208        })
209    }
210
211    pub(super) fn snapshot(&self) -> ConnectionCapSnapshot {
212        ConnectionCapSnapshot {
213            limit: self.limit,
214            configured: self.configured,
215            active: self.limit - self.permits.available_permits(),
216            refused: self.refused.load(std::sync::atomic::Ordering::SeqCst),
217        }
218    }
219}
220
221/// Admit one accepted connection, or answer it with a typed busy refusal.
222///
223/// Past the cap the peer receives a `daemon_busy` error frame (`NotCommitted`,
224/// carrying the enforced limit) and the caller drops the stream, which closes
225/// it. The refusal writes to the stream that `accept` already returned, so it
226/// needs no further descriptor. The request frame is not read, so nothing of
227/// it is dispatched. Connections that already hold a permit are not touched.
228/// The refusal is logged when the running refusal count reaches a power of two,
229/// which keeps a connection flood from flooding the log while still reporting
230/// that it began.
231#[cfg(unix)]
232pub(super) async fn admit_or_refuse_busy(
233    admission: &ConnectionAdmission,
234    stream: &mut UnixStream,
235    config_id: &str,
236) -> Option<tokio::sync::OwnedSemaphorePermit> {
237    let refused_total = match admission.try_admit() {
238        Ok(permit) => return Some(permit),
239        Err(refused_total) => refused_total,
240    };
241    if refused_total.is_power_of_two() {
242        tracing::warn!(
243            cap = admission.limit,
244            refused_total,
245            "daemon at connection cap; refusing new connections"
246        );
247    }
248    let limit = admission.limit;
249    let refusal = DaemonResponseFrame {
250        ok: false,
251        result: None,
252        error: Some(format!(
253            "daemon is at its connection limit ({limit}); request was not admitted, retry shortly"
254        )),
255        error_detail: Some(serde_json::json!({
256            "kind": "runtime", "code": "daemon_busy", "limit": limit,
257            "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
258        })),
259        namespace_mismatch: false,
260        config_mismatch: false,
261        served_config_id: Some(config_id.to_owned()),
262        version_mismatch: false,
263        daemon_protocol_version: PROTOCOL_VERSION,
264        metrics: None,
265        request_id: None,
266    };
267    if let Ok(payload) = serde_json::to_vec(&refusal) {
268        let write = write_frame(stream, &payload);
269        match tokio::time::timeout(BUSY_REFUSAL_WRITE_TIMEOUT, write).await {
270            Ok(Ok(())) => {}
271            Ok(Err(error)) => tracing::debug!(%error, "failed to write connection-limit refusal"),
272            Err(_) => tracing::debug!("connection-limit refusal write timed out"),
273        }
274    }
275    None
276}
277
278/// Connection cap state of the main daemon socket: the cap that is enforced,
279/// the connections holding a slot right now, and how many connections have been
280/// refused with the busy answer since the daemon started.
281#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
282pub struct ConnectionCapSnapshot {
283    /// The enforced cap, after any reduction to fit the process's descriptor limit.
284    pub limit: usize,
285    /// The configured or default cap, present only when the descriptor limit
286    /// forced `limit` below it.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub configured: Option<usize>,
289    pub active: usize,
290    pub refused: u64,
291}
292
293// ── bounded recall serve-ledger tasks ────────────────────────────────────────
294
295/// Environment variable that sets how many recall serve-ledger tasks may be
296/// pending at once. A zero or non-integer value is ignored with a warning and
297/// the default applies.
298const RECALL_LEDGER_MAX_PENDING_ENV: &str = "KHIVE_RECALL_LEDGER_MAX_PENDING";
299
300/// Environment variable that sets, in milliseconds, how long a recall
301/// serve-ledger task may run before it is ended. A zero or non-integer value is
302/// ignored with a warning and the default applies.
303const RECALL_LEDGER_TIMEOUT_MS_ENV: &str = "KHIVE_RECALL_LEDGER_TIMEOUT_MS";
304
305/// Default bound on pending recall serve-ledger tasks: the capacity of the
306/// database write queue they write through, so the tasks cannot pile up faster
307/// than the queue can absorb them.
308const DEFAULT_RECALL_LEDGER_MAX_PENDING: usize = 256;
309
310/// Default completion timeout for one recall serve-ledger task. A task normally
311/// finishes in milliseconds, so this only ends a task whose write is stalled.
312const DEFAULT_RECALL_LEDGER_TIMEOUT_MS: u64 = 30_000;
313
314/// The recall serve ledger and its telemetry event are best-effort records
315/// written after the recall response is built. This bounds how many such tasks
316/// are pending and how long each may run, so a stalled writer cannot accumulate
317/// tasks without limit. A recall never waits on this: admission is a non-blocking
318/// permit, and a write that is skipped at the limit or ended at its timeout is
319/// counted instead of retried.
320pub(super) struct RecallLedgerBound {
321    max_pending: usize,
322    timeout: std::time::Duration,
323    permits: Arc<tokio::sync::Semaphore>,
324    skipped: std::sync::atomic::AtomicU64,
325    timed_out: std::sync::atomic::AtomicU64,
326}
327
328impl RecallLedgerBound {
329    pub(super) fn new(max_pending: usize, timeout: std::time::Duration) -> Self {
330        let max_pending = max_pending.clamp(1, tokio::sync::Semaphore::MAX_PERMITS);
331        Self {
332            max_pending,
333            timeout,
334            permits: Arc::new(tokio::sync::Semaphore::new(max_pending)),
335            skipped: std::sync::atomic::AtomicU64::new(0),
336            timed_out: std::sync::atomic::AtomicU64::new(0),
337        }
338    }
339
340    fn from_env() -> Self {
341        let max_pending = match positive_limit_from_env(RECALL_LEDGER_MAX_PENDING_ENV) {
342            Some(value) => usize::try_from(value).unwrap_or(usize::MAX),
343            None => DEFAULT_RECALL_LEDGER_MAX_PENDING,
344        };
345        let timeout_ms = positive_limit_from_env(RECALL_LEDGER_TIMEOUT_MS_ENV)
346            .unwrap_or(DEFAULT_RECALL_LEDGER_TIMEOUT_MS);
347        Self::new(max_pending, std::time::Duration::from_millis(timeout_ms))
348    }
349
350    /// Start `fut` as a tracked task when a pending slot is free, ending it if
351    /// it has not completed within the timeout. At the limit nothing is spawned
352    /// and the skipped write is counted. Never waits.
353    pub(super) fn try_spawn<F>(self: &Arc<Self>, fut: F) -> Option<tokio::task::JoinHandle<()>>
354    where
355        F: std::future::Future<Output = ()> + Send + 'static,
356    {
357        let Ok(permit) = Arc::clone(&self.permits).try_acquire_owned() else {
358            self.skipped
359                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
360            return None;
361        };
362        let bound = Arc::clone(self);
363        let handle = spawn_named_tracked_task("memory_recall_serve_ledger", async move {
364            // Released when the task ends, completed or timed out.
365            let _permit = permit;
366            if tokio::time::timeout(bound.timeout, fut).await.is_err() {
367                bound
368                    .timed_out
369                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
370                tracing::warn!(
371                    timeout_ms = bound.timeout.as_millis() as u64,
372                    "recall serve ledger task timed out; the ledger write was missed"
373                );
374            }
375        });
376        Some(handle)
377    }
378
379    pub(super) fn snapshot(&self) -> RecallLedgerSnapshot {
380        RecallLedgerSnapshot {
381            max_pending: self.max_pending,
382            pending: self.max_pending - self.permits.available_permits(),
383            timeout_ms: self.timeout.as_millis().min(u128::from(u64::MAX)) as u64,
384            skipped: self.skipped.load(std::sync::atomic::Ordering::SeqCst),
385            timed_out: self.timed_out.load(std::sync::atomic::Ordering::SeqCst),
386        }
387    }
388}
389
390fn recall_ledger() -> &'static Arc<RecallLedgerBound> {
391    static BOUND: std::sync::OnceLock<Arc<RecallLedgerBound>> = std::sync::OnceLock::new();
392    BOUND.get_or_init(|| Arc::new(RecallLedgerBound::from_env()))
393}
394
395/// Run a recall serve-ledger task under the process-wide bound. Returns at once:
396/// the task is either started as a tracked background task or skipped and
397/// counted when the pending limit is reached.
398pub fn track_recall_ledger_task<F>(fut: F)
399where
400    F: std::future::Future<Output = ()> + Send + 'static,
401{
402    drop(recall_ledger().try_spawn(fut));
403}
404
405/// Current bounds and counts of the recall serve-ledger tasks.
406pub fn recall_ledger_snapshot() -> RecallLedgerSnapshot {
407    recall_ledger().snapshot()
408}
409
410/// State of the recall serve-ledger task bound: the configured pending limit
411/// and completion timeout, the tasks pending right now, and the ledger writes
412/// that did not complete. A write is missed when it was skipped at the limit
413/// (`skipped`) or ended at its timeout (`timed_out`).
414#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
415pub struct RecallLedgerSnapshot {
416    pub max_pending: usize,
417    pub pending: usize,
418    pub timeout_ms: u64,
419    pub skipped: u64,
420    pub timed_out: u64,
421}