Skip to content

Commit 238f8b7

Browse files
vparfonovclaude
andcommitted
fix(sources): prevent "Too many open files" by raising FD limits and adding LRU eviction (LOG-9336)
Adds a three-layer defense against file descriptor exhaustion: 1. Auto-raise RLIMIT_NOFILE soft limit to hard limit at startup, with macOS kern.maxfilesperproc fallback for RLIM_INFINITY hard limits. 2. New `max_open_files` config for the file source with LRU eviction — when the limit is reached, the least recently read file is closed (checkpoint preserved, no data loss). 3. Auto-derive a sensible default (80% of OS soft limit) so the feature works out of the box without manual configuration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 94403a5 commit 238f8b7

6 files changed

Lines changed: 404 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,8 @@ byteorder = "1.5.0"
456456
windows-service = "0.8.0"
457457

458458
[target.'cfg(unix)'.dependencies]
459-
nix = { version = "0.31", default-features = false, features = ["socket", "signal", "fs"] }
459+
libc.workspace = true
460+
nix = { version = "0.31", default-features = false, features = ["socket", "signal", "fs", "resource"] }
460461

461462
[target.'cfg(target_os = "linux")'.dependencies]
462463
netlink-packet-utils = "0.5.2"

lib/file-source/src/file_server.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ where
5858
pub remove_after: Option<Duration>,
5959
pub emitter: E,
6060
pub rotate_wait: Duration,
61+
pub max_open_files: Option<usize>,
6162
}
6263

6364
/// `FileServer` as Source
@@ -185,6 +186,21 @@ where
185186
for (_file_id, watcher) in &mut fp_map {
186187
watcher.set_file_findable(false); // assume not findable until found
187188
}
189+
190+
// Pre-build eviction candidates sorted by last_read_success (oldest first).
191+
// This avoids O(n) scans per eviction when many new files appear at once.
192+
let eviction_candidates: Vec<FileFingerprint> = if self.max_open_files.is_some() {
193+
let mut candidates: Vec<_> = fp_map
194+
.iter()
195+
.map(|(&fid, w)| (w.last_read_success(), fid))
196+
.collect();
197+
candidates.sort_unstable();
198+
candidates.into_iter().map(|(_, fid)| fid).collect()
199+
} else {
200+
Vec::new()
201+
};
202+
let mut eviction_idx = 0;
203+
188204
for path in self.paths_provider.paths().into_iter() {
189205
if let Some(file_id) = self
190206
.fingerprinter
@@ -230,6 +246,30 @@ where
230246
}
231247
} else {
232248
// untracked file fingerprint
249+
// Check max_open_files limit before adding new file
250+
if let Some(max) = self.max_open_files
251+
&& fp_map.len() >= max
252+
{
253+
while eviction_idx < eviction_candidates.len() {
254+
let evict_id = eviction_candidates[eviction_idx];
255+
eviction_idx += 1;
256+
// Skip candidates already removed from fp_map
257+
if let Some(watcher) = fp_map.swap_remove(&evict_id) {
258+
info!(
259+
message = "Evicting least recently read file due to max_open_files limit.",
260+
evicted_path = ?watcher.path,
261+
new_path = ?path,
262+
max_open_files = max,
263+
);
264+
self.emitter.emit_file_unwatched(
265+
&watcher.path,
266+
watcher.reached_eof(),
267+
);
268+
checkpoints.set_dead(evict_id);
269+
break;
270+
}
271+
}
272+
}
233273
self.watch_new_file(path, file_id, &mut fp_map, &checkpoints, false)
234274
.await;
235275
self.emitter.emit_files_open(fp_map.len());

src/cli.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,173 @@ impl RootOpts {
287287
}
288288
}
289289

290+
#[cfg(unix)]
291+
raise_file_descriptor_limit();
292+
290293
crate::metrics::init_global().expect("metrics initialization failed");
291294
}
292295
}
293296

297+
/// Raise the soft file descriptor limit (RLIMIT_NOFILE) as high as the OS allows.
298+
///
299+
/// Many systems default the soft limit to 1024 (Linux) or 256 (macOS), which is too low
300+
/// for Vector when it monitors large numbers of log files. Raising it prevents
301+
/// "Too many open files (os error 24)" errors without requiring manual sysadmin intervention.
302+
///
303+
/// On Linux, the soft limit is raised to the hard limit (typically 65536+).
304+
/// On macOS, the hard limit can be RLIM_INFINITY, so we first try the hard limit,
305+
/// then fall back to the kernel-enforced `kern.maxfilesperproc` (typically 10240).
306+
#[cfg(unix)]
307+
fn raise_file_descriptor_limit() {
308+
use nix::sys::resource::{Resource, getrlimit, setrlimit};
309+
use tracing::{info, warn};
310+
311+
let (soft, hard) = match getrlimit(Resource::RLIMIT_NOFILE) {
312+
Ok(limits) => limits,
313+
Err(err) => {
314+
warn!(message = "Failed to get file descriptor limit.", %err);
315+
return;
316+
}
317+
};
318+
319+
if soft >= hard {
320+
return; // Already at maximum
321+
}
322+
323+
// Try setting soft limit to hard limit (works on Linux, may fail on macOS)
324+
if setrlimit(Resource::RLIMIT_NOFILE, hard, hard).is_ok() {
325+
info!(
326+
message = "Raised file descriptor limit.",
327+
from = soft,
328+
to = hard,
329+
);
330+
return;
331+
}
332+
333+
// On macOS, the hard limit can be RLIM_INFINITY which setrlimit rejects.
334+
// Fall back to the kernel-enforced kern.maxfilesperproc.
335+
#[cfg(target_os = "macos")]
336+
{
337+
if let Some(maxfiles) = macos_maxfilesperproc()
338+
&& maxfiles > soft
339+
&& setrlimit(Resource::RLIMIT_NOFILE, maxfiles, hard).is_ok()
340+
{
341+
info!(
342+
message = "Raised file descriptor limit.",
343+
from = soft,
344+
to = maxfiles,
345+
);
346+
return;
347+
}
348+
}
349+
350+
warn!(
351+
message = "Failed to raise file descriptor limit.",
352+
current = soft,
353+
attempted = hard,
354+
);
355+
}
356+
357+
/// Query the macOS kernel limit on per-process open files.
358+
#[cfg(target_os = "macos")]
359+
fn macos_maxfilesperproc() -> Option<libc::rlim_t> {
360+
let mut maxfiles: libc::c_int = 0;
361+
let mut len = std::mem::size_of::<libc::c_int>() as libc::size_t;
362+
// Safety: sysctlbyname with a valid null-terminated name and correctly sized output buffer.
363+
// No safe wrapper exists for this macOS-specific call.
364+
let ret = unsafe {
365+
libc::sysctlbyname(
366+
c"kern.maxfilesperproc".as_ptr(),
367+
&mut maxfiles as *mut libc::c_int as *mut libc::c_void,
368+
&mut len,
369+
std::ptr::null_mut(),
370+
0,
371+
)
372+
};
373+
if ret == 0 && maxfiles > 0 {
374+
Some(maxfiles as libc::rlim_t)
375+
} else {
376+
None
377+
}
378+
}
379+
380+
#[cfg(test)]
381+
mod tests {
382+
#[test]
383+
#[cfg(unix)]
384+
fn test_raise_file_descriptor_limit() {
385+
use nix::sys::resource::{Resource, getrlimit, setrlimit};
386+
387+
// Save original limits
388+
let (original_soft, hard) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
389+
390+
// Lower the soft limit to simulate a constrained environment
391+
let lowered = std::cmp::min(original_soft, 256);
392+
if lowered < hard {
393+
setrlimit(Resource::RLIMIT_NOFILE, lowered, hard).unwrap();
394+
395+
// Verify it was lowered
396+
let (soft_before, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
397+
assert_eq!(soft_before, lowered);
398+
399+
// Call the function under test
400+
super::raise_file_descriptor_limit();
401+
402+
// Verify the soft limit was raised above the lowered value
403+
let (soft_after, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
404+
assert!(
405+
soft_after > lowered,
406+
"Expected soft limit to be raised above {lowered}, got {soft_after}"
407+
);
408+
409+
// Restore original limits
410+
setrlimit(Resource::RLIMIT_NOFILE, original_soft, hard).unwrap();
411+
}
412+
}
413+
414+
#[test]
415+
#[cfg(unix)]
416+
fn test_raise_file_descriptor_limit_already_at_max() {
417+
use nix::sys::resource::{Resource, getrlimit, setrlimit};
418+
419+
// Save original limits
420+
let (original_soft, hard) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
421+
422+
// Set soft = hard so there's nothing to raise
423+
if setrlimit(Resource::RLIMIT_NOFILE, hard, hard).is_err() {
424+
#[cfg(target_os = "macos")]
425+
if let Some(maxfiles) = super::macos_maxfilesperproc() {
426+
let _ = setrlimit(Resource::RLIMIT_NOFILE, maxfiles, hard);
427+
}
428+
}
429+
430+
let (soft_before, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
431+
432+
// Call the function — should be a no-op
433+
super::raise_file_descriptor_limit();
434+
435+
let (soft_after, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
436+
assert_eq!(soft_before, soft_after);
437+
438+
// Restore original limits
439+
setrlimit(Resource::RLIMIT_NOFILE, original_soft, hard).unwrap();
440+
}
441+
442+
#[test]
443+
#[cfg(target_os = "macos")]
444+
fn test_macos_maxfilesperproc_returns_positive() {
445+
let result = super::macos_maxfilesperproc();
446+
assert!(
447+
result.is_some(),
448+
"macos_maxfilesperproc() should return Some on macOS"
449+
);
450+
assert!(
451+
result.unwrap() > 0,
452+
"kern.maxfilesperproc should be positive"
453+
);
454+
}
455+
}
456+
294457
#[derive(Parser, Debug)]
295458
#[command(rename_all = "kebab-case")]
296459
pub enum SubCommand {

0 commit comments

Comments
 (0)