Skip to content

feat(file source): notify-based discovery + idle handle closing (#3567) - #26332

Open
Smelentyev wants to merge 1 commit into
vectordotdev:masterfrom
Smelentyev:fix/3567-file-source-handles
Open

feat(file source): notify-based discovery + idle handle closing (#3567)#26332
Smelentyev wants to merge 1 commit into
vectordotdev:masterfrom
Smelentyev:fix/3567-file-source-handles

Conversation

@Smelentyev

Copy link
Copy Markdown

Summary

The file source keeps a file handle open, and re-globs/re-fingerprints, every matched file
forever -- even files excluded by ignore_older -- which causes severe CPU/memory/handle
overhead once the matched file count grows large (issue #3567). This PR adds two independent,
opt-in mechanisms that together address both halves of that problem:

  • idle_timeout_secs (default: 60s): closes a file's handle once it has reached EOF and
    received no new data for that long. A cheap fs::metadata poll (not a full re-fingerprint)
    detects when an idle file gets new data again and reopens it. This is what stops the source
    from holding thousands of open handles for inactive files.
  • file_discovery_mode: notify (opt-in, default remains polling): uses OS-level file
    system event notifications (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on
    Windows) instead of periodic glob re-scanning to discover new files and wake up idle-closed
    watchers. A much less frequent periodic reconciliation pass (reconcile_interval_secs) still
    runs as a correctness backstop. This is what stops the source from paying the cost of
    re-globbing and re-fingerprinting the entire matched set on a fixed interval, which
    idle_timeout_secs alone does not address (closing a handle after inactivity still means the
    next write has to wait for the next full rescan to be noticed).

References

Closes: #3567

Vector configuration

sources:
  in:
    type: file
    include:
      - /var/log/**/*.log
    file_discovery_mode: notify
    idle_timeout_secs: 60
    reconcile_interval_secs: 300
sinks:
  out:
    type: console
    inputs: [in]
    encoding:
      codec: json

How did you test this PR?

Unit tests

cargo test -p file-source -p file-source-common

Result: 51 passed, 0 failed (30 in file-source, 21 in file-source-common).

Coverage includes the idle/active watcher state machine:

  • deactivate/reactivate cycle, including a failed-reactivate forced-retry path
  • truncation detection while idle -- both an ordinary truncate-then-refill-past-old-position
    case, and the narrower case where the truncation is observed on the exact same poll as a
    forced retry after a failed reactivation
  • same-inode truncation vs. file identity change (rotation) while idle
  • rotation while idle without misreading the wrong (replacement) file
  • gzip files, both idle detection and multi-member reads
  • notify-mode glob-pattern-to-watched-directory mapping (single *, **, nested/overlapping
    patterns, literal paths, recursive vs. non-recursive merging)

Several of these tests are regression tests for bugs found across multiple rounds of review on
this branch; each was verified to actually fail without its corresponding fix before being
committed (temporarily reverting the fix, confirming the test fails, then restoring it).

Static analysis

cargo clippy -p file-source -p file-source-common --all-targets -- -D warnings

Result: clean, no warnings.

cargo fmt -p file-source -p file-source-common

No formatting changes needed.

Manual benchmark

Compared three variants on a 20,000-file corpus (19,990 old files past ignore_older, 10 files
receiving active writes/rotation) across three environments (Docker Desktop/WSL2, native Windows,
and a real Linux VPS with --cpus=1):

Variant Open FDs/handles (steady) Avg CPU (steady, VPS) Steady-state reaction latency (VPS)
release (current default behavior) ~20,000 58.9% 3,875 ms
idle-close only (no notify) ~22 59.7% 950 ms
notify + idle-close (this PR) ~24 0.20% 2,737 ms

idle_timeout_secs alone (middle row) already fixes the file descriptor count, but still
re-globs/re-fingerprints the entire 20k-file set on a fixed interval, which shows up directly as
a CPU sawtooth pattern between polls. Adding file_discovery_mode: notify (bottom row) removes
that rescan cycle entirely -- CPU stays flat under 0.5% the whole time, only moving in response
to actual OS-level file system events.

Also surfaced along the way: release's current behavior exhausts a default Linux ulimit -n of
1024 outright at this file count (Too many open files (os error 24), dropped events, stopped
checkpoint writes) -- not a synthetic edge case, but Docker's and many distros' out-of-the-box
per-process limit. This PR's idle-close mechanism avoids that by design, peaking around 20-24
open FDs regardless of matched file count.

Does this PR include user facing changes?

  • Yes. Changelog fragment: changelog.d/3567_file_source_notify_discovery.enhancement.md.

Contributor Guidelines

…ordotdev#3567)

The file source keeps a file handle open, and re-globs/re-fingerprints, every
matched file forever -- even files excluded by ignore_older -- which causes
severe CPU/memory/handle overhead once the matched file count grows large.

Adds two independent, opt-in mechanisms that together address both halves of
that problem:

- idle_timeout_secs (default: 60s): closes a file's handle once it has
  reached EOF and received no new data for that long. A cheap fs::metadata
  poll (not a full re-fingerprint) detects when an idle file gets new data
  again and reopens it. This is what stops the source from holding thousands
  of open handles for inactive files.
- file_discovery_mode: notify (opt-in, default remains polling): uses OS-level
  file system event notifications (inotify on Linux, FSEvents on macOS,
  ReadDirectoryChangesW on Windows) instead of periodic glob re-scanning to
  discover new files and wake up idle-closed watchers. A much less frequent
  periodic reconciliation pass (reconcile_interval_secs) still runs as a
  correctness backstop. This is what stops the source from paying the cost of
  re-globbing and re-fingerprinting the entire matched set on a fixed
  interval, which idle_timeout_secs alone does not address.

A three-way benchmark (polling-only idle-close vs. notify+idle-close vs.
release) on 20,000 files, across Docker Desktop/WSL2, native Windows, and a
real Linux VPS, confirmed notify+idle-close is the only combination that
solves both the file descriptor count and the CPU/reaction-latency sides of
vectordotdev#3567 at once.

Tests: cargo test -p file-source -p file-source-common -- 51 passed, 0
failed. Covers the idle/active state machine (deactivate/reactivate,
truncation detection while idle -- including truncation observed on the same
poll as a forced post-failure retry, and truncation followed by refill past
the old read position -- same-inode vs. rotated-file identity, rotation while
idle, gzip files) and notify glob-pattern-to-watched-directory mapping.

cargo clippy -p file-source -p file-source-common --all-targets -- -D
warnings: clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Smelentyev
Smelentyev requested a review from a team as a code owner September 9, 2026 13:17
@github-actions github-actions Bot added the domain: sources Anything related to the Vector's sources label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Thank you for your contribution! Before we can merge this PR, please sign our Contributor License Agreement.

To sign, copy and post the phrase below as a new comment on this PR.

Note: If the bot says your username was not found, the email used in your git commit may not be linked to your GitHub account. Fix this at github.com/settings/emails, then comment recheck to retry.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b1d3b15b1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +632 to +633
} else {
(Box::new(BufReader::new(gzip_multiple_decoder(reader))), 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve skipped gzip state when reactivating

When a gzip file starts with read_from: end, FileWatcher::new represents the intentionally skipped stream with a null reader and position 0. After the default idle timeout deactivates it, even an mtime-only change causes this branch to install a real gzip decoder because the position is still 0, emitting the entire backlog that read_from: end was supposed to skip. The idle state needs to retain whether the gzip stream was intentionally suppressed rather than inferring that solely from file_position.

Useful? React with 👍 / 👎.

Comment on lines +165 to +169
// Only record success: if `watch` failed, leaving this path out of
// `watched_dirs` means the next `resync_watches` call (from the backstop
// reconciliation pass) will see it as still "wanted but not yet watched" and
// retry, rather than wrongly concluding the watch is already in place and
// never trying again.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Watch an existing ancestor when the configured root is absent

In notify mode, if the literal prefix of an include such as /var/log/newapp/*.log does not exist at startup, installing its watch fails and the only retry occurs during the backstop reconciliation. Because no existing ancestor is watched, creating newapp cannot wake the source; with the default interval its files are delayed by up to five minutes, and short-lived files can disappear before discovery. Watch the nearest existing ancestor or temporarily poll at the normal discovery cadence until the desired root exists.

Useful? React with 👍 / 👎.

Comment on lines +159 to +160
if self.watched_dirs.get(path) == Some(mode) {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-establish a watch after its root directory is removed

On Linux/inotify, removing a watched directory invalidates the watch on that inode, but a PathsRemoved reconciliation leaves the directory recorded in watched_dirs. Consequently this equality check skips watch() after the directory is recreated, and since its parent is not watched the source remains permanently dependent on the five-minute full reconciliations, potentially losing short-lived files. A removal affecting a watched root must evict or invalidate that registration before resynchronizing.

Useful? React with 👍 / 👎.

Comment on lines +564 to +567
let drain_result =
tokio::time::timeout(NOTIFY_EVENT_DEBOUNCE, async {
while discovery.recv().await.is_some() {}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Process control messages while debouncing events

If an ordinary path event is followed by BackendError during this 50 ms drain window, the loop discards the error without running the match above, so forget_watches() is never called and lost backend registrations remain marked active. The same drain also suppresses overflow telemetry. Debounce path notifications while still processing each drained control message through the normal error-handling path.

Useful? React with 👍 / 👎.

Comment on lines +715 to +716
if !watcher.is_idle() || !watcher.file_findable() {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reap idle watchers that leave the include glob

When an idle file is rotated to a path outside a narrow include glob such as *.log, discovery marks its watcher unfindable and this guard prevents it from ever being polled or reaching the active read path that normally marks an unfindable EOF watcher dead. It is then retained until rotate_wait, whose default is effectively infinite, so every rotation permanently adds another watcher and checkpoint to fp_map; long-running sources can accumulate unbounded state. Unfindable idle watchers need a finite reaping path independent of the default rotation wait.

Useful? React with 👍 / 👎.

Comment on lines +741 to +744
if let Some(grace_period) = self.remove_after
&& watcher
.idle_since()
.is_some_and(|idle| idle >= grace_period)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Measure remove_after from EOF rather than deactivation

For remove_after_secs values longer than idle_timeout_secs, this check starts the full removal grace period again when the watcher enters Idle, because idle_since is initialized during deactivation rather than when EOF or the last data was observed. With the defaults, for example, remove_after_secs: 120 removes no earlier than roughly 180 seconds, contrary to the documented 120 seconds after EOF, and notify mode can add up to another reconciliation interval. Preserve the original last-activity time when transitioning to idle.

Useful? React with 👍 / 👎.

Comment on lines +291 to +295
let discovery_interval = if using_notify {
self.reconcile_interval
} else {
self.glob_minimum_cooldown
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the reconciliation interval after notify initialization fails

When file_discovery_mode is Notify but constructing the OS watcher fails, using_notify becomes false and this branch polls using glob_minimum_cooldown, even though that option is documented as ignored in notify mode and the fallback is documented to use reconcile_interval. A configuration that leaves reconcile_interval_secs at five minutes but sets the supposedly ignored glob cooldown to an hour will therefore discover files only hourly whenever inotify resources are exhausted. Select the interval from the configured discovery mode and fallback state rather than watcher presence alone.

Useful? React with 👍 / 👎.

Comment on lines +658 to +662
if watcher.path == path {
trace!(
message = "Continue watching file.",
path = ?path,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wake quiet active files when a notify event arrives

When an existing active watcher has been quiet for more than 10 seconds but has not yet reached the 60-second idle timeout, a notify event runs discovery but this same-path branch only refreshes findability. FileWatcher::should_read still suppresses reads until 10 seconds have elapsed since its last attempt, so an append arriving just after that attempt can wait almost 10 seconds despite notify mode promising prompt wakeups; the same applies indefinitely when idle closing is disabled. The notify path needs to reset the matching watcher's EOF/read retry state or otherwise mark it immediately readable.

Useful? React with 👍 / 👎.

Comment on lines +54 to +56
impl InternalEvent for FilesIdle {
fn emit(self) {
gauge!(GaugeName::IdleFiles).set(self.count as f64);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the idle-files gauge when the source stops

After a source has any idle watchers, component_idle_files retains its last nonzero value after shutdown because the teardown in src/sources/file.rs:795 resets only FileOpen. Metrics recorders continue exporting the gauge series, so source removal or reload leaves monitoring reporting files that are no longer tracked until the process restarts. Emit FilesIdle { count: 0 } alongside the existing open-files reset.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: sources Anything related to the Vector's sources

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Should vector be maintaining open file handles to ignore_older files older than the cutoff?

1 participant