Skip to content

Commit 55ddda9

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 55ddda9

6 files changed

Lines changed: 410 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: 41 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,22 @@ 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> =
193+
if self.max_open_files.is_some() {
194+
let mut candidates: Vec<_> = fp_map
195+
.iter()
196+
.map(|(&fid, w)| (w.last_read_success(), fid))
197+
.collect();
198+
candidates.sort_unstable();
199+
candidates.into_iter().map(|(_, fid)| fid).collect()
200+
} else {
201+
Vec::new()
202+
};
203+
let mut eviction_idx = 0;
204+
188205
for path in self.paths_provider.paths().into_iter() {
189206
if let Some(file_id) = self
190207
.fingerprinter
@@ -230,6 +247,30 @@ where
230247
}
231248
} else {
232249
// untracked file fingerprint
250+
// Check max_open_files limit before adding new file
251+
if let Some(max) = self.max_open_files {
252+
if fp_map.len() >= max {
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+
}
273+
}
233274
self.watch_new_file(path, file_id, &mut fp_map, &checkpoints, false)
234275
.await;
235276
self.emitter.emit_files_open(fp_map.len());

src/cli.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,178 @@ 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+
if maxfiles > soft {
339+
if setrlimit(Resource::RLIMIT_NOFILE, maxfiles, hard).is_ok() {
340+
info!(
341+
message = "Raised file descriptor limit.",
342+
from = soft,
343+
to = maxfiles,
344+
);
345+
return;
346+
}
347+
}
348+
}
349+
}
350+
351+
warn!(
352+
message = "Failed to raise file descriptor limit.",
353+
current = soft,
354+
attempted = hard,
355+
);
356+
}
357+
358+
/// Query the macOS kernel limit on per-process open files.
359+
#[cfg(target_os = "macos")]
360+
fn macos_maxfilesperproc() -> Option<libc::rlim_t> {
361+
let mut maxfiles: libc::c_int = 0;
362+
let mut len = std::mem::size_of::<libc::c_int>() as libc::size_t;
363+
// Safety: sysctlbyname with a valid null-terminated name and correctly sized output buffer.
364+
// No safe wrapper exists for this macOS-specific call.
365+
let ret = unsafe {
366+
libc::sysctlbyname(
367+
b"kern.maxfilesperproc\0".as_ptr() as *const libc::c_char,
368+
&mut maxfiles as *mut libc::c_int as *mut libc::c_void,
369+
&mut len,
370+
std::ptr::null_mut(),
371+
0,
372+
)
373+
};
374+
if ret == 0 && maxfiles > 0 {
375+
Some(maxfiles as libc::rlim_t)
376+
} else {
377+
None
378+
}
379+
}
380+
381+
#[cfg(test)]
382+
mod tests {
383+
#[test]
384+
#[cfg(unix)]
385+
fn test_raise_file_descriptor_limit() {
386+
use nix::sys::resource::{Resource, getrlimit, setrlimit};
387+
388+
// Save original limits
389+
let (original_soft, hard) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
390+
391+
// Lower the soft limit to simulate a constrained environment
392+
let lowered = std::cmp::min(original_soft, 256);
393+
if lowered < hard {
394+
setrlimit(Resource::RLIMIT_NOFILE, lowered, hard).unwrap();
395+
396+
// Verify it was lowered
397+
let (soft_before, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
398+
assert_eq!(soft_before, lowered);
399+
400+
// Call the function under test
401+
super::raise_file_descriptor_limit();
402+
403+
// Verify the soft limit was raised above the lowered value
404+
let (soft_after, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
405+
assert!(
406+
soft_after > lowered,
407+
"Expected soft limit to be raised above {lowered}, got {soft_after}"
408+
);
409+
410+
// Restore original limits
411+
setrlimit(Resource::RLIMIT_NOFILE, original_soft, hard).unwrap();
412+
}
413+
}
414+
415+
#[test]
416+
#[cfg(unix)]
417+
fn test_raise_file_descriptor_limit_already_at_max() {
418+
use nix::sys::resource::{Resource, getrlimit, setrlimit};
419+
420+
// Save original limits
421+
let (original_soft, hard) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
422+
423+
// Set soft = hard so there's nothing to raise
424+
setrlimit(Resource::RLIMIT_NOFILE, hard, hard)
425+
.or_else(|_| {
426+
// On macOS, hard might be RLIM_INFINITY; use a fallback
427+
#[cfg(target_os = "macos")]
428+
if let Some(maxfiles) = super::macos_maxfilesperproc() {
429+
return setrlimit(Resource::RLIMIT_NOFILE, maxfiles, hard);
430+
}
431+
Err(nix::errno::Errno::EINVAL)
432+
})
433+
.ok();
434+
435+
let (soft_before, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
436+
437+
// Call the function — should be a no-op
438+
super::raise_file_descriptor_limit();
439+
440+
let (soft_after, _) = getrlimit(Resource::RLIMIT_NOFILE).unwrap();
441+
assert_eq!(soft_before, soft_after);
442+
443+
// Restore original limits
444+
setrlimit(Resource::RLIMIT_NOFILE, original_soft, hard).unwrap();
445+
}
446+
447+
#[test]
448+
#[cfg(target_os = "macos")]
449+
fn test_macos_maxfilesperproc_returns_positive() {
450+
let result = super::macos_maxfilesperproc();
451+
assert!(
452+
result.is_some(),
453+
"macos_maxfilesperproc() should return Some on macOS"
454+
);
455+
assert!(
456+
result.unwrap() > 0,
457+
"kern.maxfilesperproc should be positive"
458+
);
459+
}
460+
}
461+
294462
#[derive(Parser, Debug)]
295463
#[command(rename_all = "kebab-case")]
296464
pub enum SubCommand {

0 commit comments

Comments
 (0)